Per-window model switcher, subagent router, and automatic 429 failover for Claude Code — switch any window from the /provider panel, route subagents to any…

<img src="docs/assets/banner-light.svg" width="900" alt="model-relay: per-window model switcher, subagent router, and 429 failover for Claude Code">
<b>Switch any Claude Code window to another model from an interactive panel, route subagents to any provider,<br>and automatically fail over when Claude hits a usage limit—all on localhost, with Remote Control and statuslines intact.</b>
<a href="https://github.com/cablate/model-relay/releases"><img src="https://img.shields.io/github/v/release/cablate/model-relay?include_prereleases&display_name=release&label=release&color=38bdf8" alt="Latest release"></a> <a href="https://github.com/cablate/model-relay/actions/workflows/ci.yml"><img src="https://github.com/cablate/model-relay/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <img src="https://img.shields.io/badge/Claude_Code-2.1.287%2B-a78bfa" alt="Claude Code 2.1.287 or later"> <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
<b>English</b> · <a href="README.zh-TW.md">繁體中文</a> · <a href="#install">Install</a> · <a href="#whats-inside">Features</a> · <a href="#configuration">Configuration</a> · <a href="CHANGELOG.md">Changelog</a>
<img src="docs/assets/panel.svg" width="900" alt="Animation: on the left, main chat uses Claude while subagents route to any provider and local gateways auto-launch; on the right, the interactive AbovePrompt panel lets you test providers and switch only the current window with one click">
Claude Code binds model settings globally across every terminal window. model-relay runs a lightweight router on localhost (127.0.0.1:8950) so each window and subagent can use the model you want—with zero external cloud proxies.
| When… | Without model-relay | With model-relay |
|---|---|---|
| You want to switch just one window to another model | Editing settings.json switches every open terminal at once | Type /provider to switch only the current window from the panel above your prompt (and write its active model to model-relay-status/<session id> for your statusline) |
You want haiku or a specific subagent on another model | Changing global env vars forces your main Opus/Sonnet chat onto that endpoint too | /provider alias haiku <name> offloads haiku globally, or model-relay:<name> spawns a one-off subagent on that provider with all tools intact |
| Claude hits a 5-hour usage limit (HTTP 429) or a provider hiccups | Your turn fails mid-task until quota resets | Automatic retries and failover.chain hand the request to your backup provider and pin only that window while new windows return to Claude after cooldown |
You use Remote Control (claude.ai/code) from your phone or browser | Custom ANTHROPIC_BASE_URL blocks Remote Control from connecting | Local HTTPS proxy mode (/provider rc on) keeps Remote Control working while routing each window independently |
Third-party gateways lose history/tools or reject ToolSearch / cache_control | message-threads deltas cause mid-chat amnesia, and ToolSearch hides tools or triggers HTTP 400 | Automatically sends full conversations keeps ToolSearch working, and cleans cache_control per provider on the fly |
model-relay is built for Claude Code users who combine a Claude subscription with backup or specialized endpoints (z.ai, DeepSeek, Kimi, MiniMax, OpenRouter, or a gateway on your own machine). Each provider has to accept Anthropic's /v1/messages format; a service that only speaks OpenAI Chat Completions needs a local translator (see Limitations). If you need a multi-tenant cloud proxy for an entire organization, use LiteLLM instead—model-relay is a single-user localhost daemon.
Requires Node.js 20+ and Claude Code 2.1.287 or later (tested on Node.js 22.23.2 and Claude Code 2.1.306).
⚠️ Upgrading from 0.6 or earlier? Updating the plugin alone is not enough. Version 0.7+ reads only
"format": 2in~/.claude/model-relay.json. Until you migrate your config and restart the router, an old 0.6 router still running in the background keeps running 0.6 code (without 0.7's conversation/tool loss fix or localhost security check), and once that process stops, the plugin will not start a new router until you migrate. Run/provider setup upgrade my model-relay configand Claude does it with you, or follow UPGRADING.md by hand (node tools/setup.mjs migrate, thenmigrate --go, then/provider restart).
claude plugin marketplace add cablate/model-relay
claude plugin install model-relay@model-relay
export BACKUP_API_KEY="your-api-key" in ~/.zshrc (macOS/Linux) or setx BACKUP_API_KEY "your-api-key" (Windows), then open a new terminal./reload-plugins first if Claude Code was already open. /provider setup
Claude runs the setup tool for you, never asks you to paste a key into the chat, and asks before it changes ~/.claude/settings.json. Add what you want, such as /provider setup use deepseek for haiku.
/provider doctor --ping
You should see Config OK, your provider under Providers, and a ✓ for it under Upstream ping:
Config OK (~/.claude/model-relay.json)
Port: 8950
Providers: backup -> your-model-id
Failover: backup (back to Claude after 10 min) Retry: on
Aliases: none (run "node tools/setup.mjs alias haiku <provider>" to route haiku)
Claude settings: ✓ settings.json ANTHROPIC_BASE_URL=http://127.0.0.1:8950
Router health: ✓ up on 127.0.0.1:8950 (pid 38780, <version>, pinned sessions: 0, 429 failover: idle, proxy off (0 routed / 0 tunnelled))
Upstream ping:
✓ backup (your-model-id) -> answers, first byte after 640 ms
Anything doctor finds is listed with a ⚠ and what fixes it; /provider setup can run those fixes with you. doctor only reports and changes nothing; add --fix (/provider doctor --fix) to let it apply the repairs it can make itself, such as pointing ANTHROPIC_BASE_URL in ~/.claude/settings.json at the relay. doctor --json prints the same findings for an assistant or a script, each with the setup.mjs arguments that fix it (fix) and whether that command changes something you should agree to first (needsConsent).
Nothing goes to a provider unless you say so. A new config keeps haiku and every other request on Claude; the first provider you add becomes the one that takes over on a 429 (failover.chain). To send Claude Code's haiku calls (subagents, WebFetch summaries, internal helpers) to a provider, run node tools/setup.mjs alias haiku <name>.
Setting it up by hand: run /provider init (creates ~/.claude/model-relay.json with no provider yet, so every request still goes to Claude, and points ANTHROPIC_BASE_URL in ~/.claude/settings.json at the relay). Add a provider with node tools/setup.mjs add <name> --preset <preset> --model <model id> --token env:<VAR> (presets lists the built-in ones; --url takes any other Anthropic-compatible address), restart Claude Code, then run /provider doctor --ping.
Where node tools/setup.mjs runs: the node tools/setup.mjs ... commands later in this README are the same tool. They run from the plugin's install directory, which is ~/.claude/plugins/cache/model-relay/model-relay/<version>/ ($CLAUDE_CONFIG_DIR/plugins/cache/model-relay/model-relay/<version>/ when you set CLAUDE_CONFIG_DIR; ls that folder for your version). Elsewhere, use the full path to tools/setup.mjs inside it. The command acts on the config directory in $CLAUDE_CONFIG_DIR, or ~/.claude if unset. /provider setup runs them for you from anywhere.
A key exported later is not seen: env:VAR tokens are read from the router process's own environment, which it inherits from whichever Claude Code session (or setup command) started it. A variable exported after the router started is not seen until the router restarts: stop the router process (doctor prints its pid) and start a new Claude Code session. doctor reads your shell, so it can report the key as set while the router cannot see it.
Updating: run claude plugin update model-relay@model-relay. The plugin then restarts the relay by itself once no request is in flight through it; a relay from 0.6 or earlier cannot report that, so a notice asks you to run /provider restart when no Claude Code window has a reply in progress.
AbovePrompt control panel and provider testType /provider to open a panel right above your prompt input box. It does one thing: pick which model this window uses, one button per row. Once a switch takes, the panel closes and a notice names the model now in use. Click a button, or press ctrl+x tab and then the key shown on it (1–9 to switch, p to test every provider, and r to try Claude again while a 429 cooldown runs). Switching a pinned window back to Claude takes a second press to confirm. The switches every window shares stay folded under Settings [s]: press it to show 429 failover, retry and Remote Control (f / t / x) and the model aliases. They turn on or off without editing JSON: failover and retry apply from the next request, while Remote Control asks first because it rewrites settings.json: turning it on reaches new sessions only, and turning it off disconnects sessions already running in proxy mode until they are restarted.
Each provider row shows two things apart: the last real request (last 200 · 45.0s) and the last test (test ok 640ms). The test sends one 1-token request and stops at the first byte (15 seconds at most), so a long reply never reads as a slow provider, and a failure says what is wrong in words: the key was refused, the model name is wrong, the quota is used up, the local gateway is not running, or there was no answer in time. The router keeps the provider's key out of anything it shows. When the router skips a provider (its env: variable is unset for the router), the panel says so.
<img src="docs/assets/relay-flow.svg" width="900" alt="Animation: a Claude Code session hits a 429 usage limit; model-relay seamlessly fails over to a backup provider and pins that window while new windows return to Claude after cooldown">
When Anthropic returns a 429 usage-limit response, the router immediately forwards the request to the first provider in failover.chain and pins that session ID for 7 days. After failover.cooldownMs (default 10 minutes), new sessions probe Claude again while existing pinned sessions stay on their backup provider. failover.enabled switches the whole thing off.
A provider answering 429 or 5xx is retried on the SAME provider with exponential backoff (retry.attempts/delayMs/backoff), capped by retry.maxDelayMs; a longer Retry-After keeps polling at that cap. Set retry.attempts: 0 to retry forever (keep hammering until it succeeds — the client connection is watched, so retries stop once the caller disconnects). Claude Code never sees the intermediate failures. retry.enabled turns retrying off everywhere in one go. Turn on retry.failover and a request that still fails once its retries run out is handed to the next provider in failover.chain (or to that provider's own fallback list), never back to one it already tried.
Built-in presets (zai, deepseek, kimi, minimax, openrouter) fill in a service's address and the compatibility switches it needs. You always choose the model, because model IDs go out of date, and give the token (or "env:VAR_NAME"). node tools/setup.mjs presets lists them. For local gateways on 127.0.0.1, set launch: { command, args } and the router will automatically spawn the gateway process whenever its port is closed.
haiku / sonnet / opus at a provider)model-relay separates three routing layers so they never get mixed up:
matchModels + ANTHROPIC_DEFAULT_*_MODEL):** Routes only requests whose model matches (such as haiku subagents or WebFetch summaries) without pinning the main conversation./provider <name>): Pins only the current conversation ID (x-claude-code-session-id) for 7 days; other windows stay on Claude.failover.chain): Takes over only when Anthropic returns a 429 usage-limit error.Pointing a Claude tier like haiku at a provider (for example backup, with model your-model-id[1m]) coordinates two places at once:
~/.claude/settings.json → env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "your-model-id[1m]" so Claude Code itself resolves the haiku alias to that model (including the [1m] context window) and sends "model": "your-model-id".~/.claude/model-relay.json → providers.backup.matchModels gains "haiku", so the router also catches internal Claude Code calls that hardcode "claude-haiku-*", rewriting the wire model field to "your-model-id". A request for a provider's own model always reaches that provider. node tools/setup.mjs alias haiku claude undoes both.How a request finds its provider: a request whose model is some provider's own model goes to that provider first, whatever the order in the config. Only then are the matchModels patterns tried, in config order, and the first match wins. A request caught by a pattern keeps the model name it came with, except a Claude name (claude-*, haiku, sonnet, opus), which is replaced by the provider's model. So a pattern should match the real model ID the upstream knows, not the provider's name in this file: ^backup would catch requests named after the provider and pass on a name the upstream has never heard of.
Use setup.mjs alias (or /provider alias) to inspect or set both files in one step:
node tools/setup.mjs alias # inspect haiku / sonnet / opus / subagent default
node tools/setup.mjs alias haiku backup # point haiku at backup in both settings.json and model-relay.json
node tools/setup.mjs alias haiku claude # reset haiku back to Anthropic Claude
node tools/setup.mjs alias subagent sonnet # set CLAUDE_CODE_SUBAGENT_MODEL=sonnet in settings.json
For per-call subagent routing, model-relay also dynamically registers a full-capability Agent type model-relay:<name> for each provider the router serves (such as model-relay:backup) via $.agent.register with all parent tools and model set to that provider's model ID, and locks that model in on('agent.spawn')—so you can spawn subagent_type: "model-relay:backup" on the fly without creating .claude/agents/*.md files or restarting the session.
Each provider can shape its agent with an agent object in ~/.claude/model-relay.json. The fields are the ones an agent file's frontmatter takes, used as written; name and model always stay the provider's. description is the line Claude reads in its list of agent types when it decides which one to spawn, so say when to pick this agent:
"providers": {
"backup": {
"model": "your-model-id",
"agent": {
"description": "Use for routine, well-scoped tasks: code search, summaries, small edits.",
"effort": "medium",
"maxTurns": 40
}
}
}
A change to agent applies from your next message, without restarting Claude Code. Tested on Claude Code 2.1.308: permissionMode cannot loosen or tighten a parent running in bypassPermissions; initialPrompt is not applied to subagents started by the Agent tool (it is for claude --agent); memory had no effect, the same as in an ordinary .claude/agents/*.md file.
Claude Code can still run such a subagent on another model without saying so (a model missing from availableModels is swapped, with a line only in its debug log). The plugin checks twice: when the subagent starts, against the model Claude Code reports it started on, and when it finishes, against the models the router actually received. Either mismatch shows a notice that points to /provider doctor.
add, set, providers, remove and migrate change the file without hand-editing JSON. Every edit keeps a .model-relay-backup copy next to the file, tokens are never printed, and the running router picks the change up on its next request. On an older config (see format under Configuration), add, set, providers, remove, alias, init, restart, rc and proxy enable refuse and tell you to run migrate first; migrate is the only command that understands it. doctor also warns about an older config, a failover name that matches no provider, two providers that name the same model (the first one in the file gets it), an availableModels list in ~/.claude/settings.json that leaves a provider's model out, and a running router older than the plugin.
node tools/setup.mjs providers
node tools/setup.mjs add local --url http://127.0.0.1:4000 --model your-local-model-id --token env:LOCAL_GATEWAY_TOKEN
node tools/setup.mjs set failover.chain backup,local
node tools/setup.mjs set providers.local.fallback none
node tools/setup.mjs migrate --go
remove <name> takes the provider out of failover.chain and out of other providers' fallback lists, but it does not touch the ANTHROPIC_DEFAULT_*_MODEL aliases in ~/.claude/settings.json. If haiku, sonnet or opus pointed at the removed provider's model, Claude Code keeps sending that model name and, with no provider left to claim it, the request goes to Claude. Reset each of those aliases yourself with node tools/setup.mjs alias haiku claude (or alias haiku <name> to give it to another provider; likewise for sonnet and opus). doctor warns about an alias that no provider matches.
Remote Control only starts when Claude Code talks to api.anthropic.com, which a base URL pointing at the relay breaks. HTTPS proxy mode keeps that address and makes the relay the proxy instead: Claude Code connects through HTTPS_PROXY, the relay answers for api.anthropic.com with a certificate that Claude Code trusts through NODE_EXTRA_CA_CERTS, and model calls are routed exactly as before (providers, sticky failover, retries). Everything else, token refresh and Remote Control traffic included, passes to Anthropic unchanged.
node tools/setup.mjs proxy enable --go
node tools/setup.mjs rc
api.anthropic.com (a critical name constraint), its private key is discarded right after signing, and only Claude Code processes that carry NODE_EXTRA_CA_CERTS load it. The system certificate store is never touched.api.anthropic.com untouched, so Claude Code keeps working, just unrouted. A stopped router is different: the plugin's automatic direct fallback only works in ANTHROPIC_BASE_URL mode, so in proxy mode Claude Code cannot connect until the router is restarted (node tools/setup.mjs rc).proxy disable --go restores ANTHROPIC_BASE_URL, and the previous settings.json is kept as settings.json.model-relay-backup. Sessions already running in proxy mode lose their connection until you restart them.HTTPS_PROXY lives in settings.json env, so it also applies to commands Claude Code runs (git, curl, gh): they go through the relay as blind tunnels, and fail while the router is down.
Configuration lives in ~/.claude/model-relay.json. The directory defaults to $CLAUDE_CONFIG_DIR when set, falling back to $USERPROFILE/.claude or $HOME/.claude. model-relay.example.json shows a provider from a preset and a local gateway.
| Field | Default | Meaning |
|---|---|---|
format | (none) | 2 for a config in the current format (init, add and migrate --go set it), the only format the router reads. A file without it, or one named custom-agent.json, is an older config: until migrate --go rewrites it, the plugin starts and replaces no router for it, so a 0.6 router still running keeps using it, and with none answering the session talks to Claude directly. The plugin says so in a notice, the panel and, with statusBar on, the status line; see UPGRADING.md |
port | 8950 | Localhost port for the relay HTTP server (1025–65535). Change it with node tools/setup.mjs set port <n>, which also updates settings.json; the router already running keeps the old port until you stop it (the command prints its pid), and it refuses a port that already answers |
statusBar | false | Also show the relay state in Claude Code's plugin status line, the yellow line above the input. Off by default: each window's state is written to a status file for your own statusline instead (see below). Notices about upgrades and outages still appear as toasts |
claudeUrl | "https://api.anthropic.com" | Upstream Anthropic API base URL |
idleTimeoutMs | 300000 | How long an upstream may send nothing before the request counts as failed and follows the retry and failover rules (1000–3600000); a streaming answer that keeps sending data is never cut |
proxy.enabled | false | Accept HTTPS-proxy connections for Remote Control. Off by default. When on, a CONNECT to any host other than api.anthropic.com is a blind tunnel that any local process can use; node tools/setup.mjs proxy enable --go turns it on and proxy disable --go off |
failover.enabled | true | Automatically switch to the failover chain on Claude 429 usage limits |
failover.chain | [] | Ordered providers that take over when Claude is out of quota or a provider keeps failing; unknown names are skipped. Empty means a 429 from Claude is passed back as it is (add puts the first provider of a new config here) |
failover.cooldownMs | 600000 | Milliseconds before probing Claude again for new sessions (10 minutes). Kept between 60000 (1 minute) and 7200000 (2 hours): a value outside that range is moved to the nearest limit |
retry.enabled | true | Master switch for retrying a failing provider; false keeps the other retry settings for later |
retry.attempts | 3 | Times the SAME provider is retried with backoff when it answers 429/5xx; 0 means retry forever. Any other value is capped at 30 |
retry.delayMs | 2000 | First retry delay; multiplied by retry.backoff each attempt. Capped at 60000 (60 seconds), or 300000 (300 seconds) when set in a provider's own retry |
retry.backoff | 2 | Multiplier between retries (2s → 4s → …), capped by retry.maxDelayMs. Kept between 1 and 4 |
retry.maxDelayMs | 10000 | Longest single wait; a longer Retry-After keeps polling at this interval. Capped at 300000 (300 seconds) |
retry.failover | false | Opt-in: after retries are exhausted, forward the original request to the next provider in the chain instead of returning the error |
providers.<name>.retry |
hooks/register.ts 712 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiOpenResult } from 'claude-code'
3import type { AvatarEntry, AvatarView, PanelNote, PanelUi } from '../types'
4import {
5 AVATAR_EMPTY,
6 AVATAR_START,
7 CONFIG_NAME,
8 OLD_CONFIG_NAME,
9 PING_LIMIT_S,
10 avatarArgv,
11 avatarBytes,
12 avatarLabel,
13 avatarMade,
14 avatarName,
15 avatarScratch,
16 avatarSource,
17 avatarWanted,
18 base64ToBytes,
19 buildPanelData,
20 elsewhereText,
21 foreignConfigDir,
22 foreignStatusText,
23 has,
24 healthArgv,
25 healthWaitArgv,
26 helpText,
27 missedRelayText,
28 outdatedText,
29 parseHealth,
30 parseLocal,
31 pingArgv,
32 pingReport,
33 probeArgv,
34 providerAgentSpecs,
35 providerAgentOptions,
36 providerReport,
37 rasterCells,
38 reachedRelay,
39 relayAgent,
40 routerIsStale,
41 routerScript,
42 routerUrl,
43 sessionArgv,
44 setupBrief,
45 setupCliArgv,
46 setupCommandArgs,
47 setupScript,
48 spawnedElsewhere,
49 staleStatusText,
50 startArgv,
51 startFailedText,
52 statusFields,
53 statusFile,
54 statusText,
55 switchedText,
56 toggleArgv,
57 type AvatarAim,
58 type AvatarWant,
59 type Health,
60 type LocalCfg,
61} from './logic'
62import { avatarTree } from './avatar'
63import { panelTree, type PanelActions } from './panel'
64
65const panelUi = atom(
66 { plugin: 'model-relay', key: 'panelUi' } as const,
67 { open: false, confirmingClaude: false, pinging: false } satisfies PanelUi,
68)
69const panelData = atom({ plugin: 'model-relay', key: 'panelData' } as const, null)
70// Change the tag when AvatarView's fields change: a value an earlier version stored outlives a reload
71const avatarView = atom({ plugin: 'model-relay', key: 'avatarView' } as const, AVATAR_EMPTY, { shape: 'avatar-2' })
72// Asks for a wide dock: the picture has more cells to draw glyphs in the wider it is (the engine keeps it to 45% of the terminal)
73const AVATAR_PANE = 'model-relay-avatar'
74const AVATAR_OPEN = { id: AVATAR_PANE, title: 'model-relay avatar', columns: 80 }
75
76let ticking = false
77let shownStatus: string | undefined = ''
78let statusBar = false // the config's "statusBar", read each tick
79let writtenStatus = '' // path and line last written, so a new session id writes its own file at once
80let statusDir: string | undefined
81let writtenStatusAt = 0
82let lastStart = 0
83let direct = false // this session bypasses a dead router and talks to Anthropic itself
84let startFailed = false // the router was started and did not answer; said once until it answers again
85let registeredSig: string | null = null
86let pluginVersion: string | null = null
87let lastStaleTry = 0
88let staleBusy = false // the router runs older code but was answering a request when we last tried to replace it
89let outdatedTold = false // the toast about an older config, once per session
90// The providers our own router last reported; kept through an outage so a model-relay:<name> spawn still gets its model
91let knownProviders: Record<string, string> = {}
92// model-relay:<name> subagents started and not finished yet, by agentId: checked against the router when their turn ends
93const pending = new Map<string, { name: string; model: string; since: number }>()
94let avatarEntries: Record<string, AvatarEntry> = {} // the config's "avatars", read each tick
95let avatarAim: AvatarAim = AVATAR_START // what the avatar pane is asked to show; its view catches up
96let avatarBusy = false // a conversion is running: one at a time
97let avatarSwitch: boolean | null = null // the config's "avatarPane" (with pictures to show) as last followed; null before the first tick
98// The last pictures made, by what they were made from, so switching back to a model draws at once; reopening the pane empties it
99const avatarMemo = new Map<string, string>()
100const AVATAR_MEMO_SIZE = 8
101
102const COMMAND_WORDS = ['claude', 'status', 'help', 'ping', 'setup', 'doctor', 'restart', 'failover', 'retry', 'rc', 'alias', 'avatar']
103
104async function myVersion($: EngineInterface): Promise<string> {
105 if (pluginVersion === null) {
106 try {
107 pluginVersion = String(JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)).version ?? '')
108 } catch {
109 pluginVersion = ''
110 }
111 }
112 return pluginVersion
113}
114
115// One model-relay:<name> agent per provider, and the provider names in /provider's hint; redone when the router's list changes
116async function syncProviders($: EngineInterface, providers: Record<string, string>): Promise<void> {
117 knownProviders = providers
118 let options: ReturnType<typeof providerAgentOptions> = {}
119 try {
120 options = providerAgentOptions(JSON.parse(await $.fs.read(`${await configDir($)}/${CONFIG_NAME}`)))
121 } catch {}
122 const specs = providerAgentSpecs(providers, options)
123 const sig = JSON.stringify(specs)
124 if (sig === registeredSig) return
125 const plain = providerAgentSpecs(providers)
126 for (const [i, spec] of specs.entries()) {
127 const ok = await $.agent.register(spec).then(() => true, () => false)
128 // Claude Code refused this provider's "agent" fields: keep the agent with the defaults rather than lose it
129 if (!ok && has(options, spec.name)) {
130 await $.agent.register(plain[i]!).catch(() => undefined)
131 const lang = await detectLang($)
132 $.ui.toast(lang === 'en'
133 ? `Claude Code refused providers.${spec.name}.agent in model-relay.json; model-relay:${spec.name} uses the defaults`
134 : `Claude Code 不接受 model-relay.json 的 providers.${spec.name}.agent,model-relay:${spec.name} 先用預設值`)
135 }
136 }
137 await $.command.register({
138 name: 'provider',
139 description: '切換這個視窗的模型(不帶參數開面板,/provider help 看全部指令)',
140 argumentHint: `[${[...Object.keys(providers), ...COMMAND_WORDS].join('|')}]`,
141 })
142 registeredSig = sig
143}
144
145async function isWindows($: EngineInterface): Promise<boolean> {
146 const os = await $.env.get('OS')
147 const up = await $.env.get('USERPROFILE')
148 return Boolean((os && /windows/i.test(os)) || up)
149}
150
151async function detectLang($: EngineInterface): Promise<'zh' | 'en'> {
152 try {
153 const s = await $.settings.read()
154 const lang = String((s as Record<string, unknown>)?.language ?? '')
155 if (/zh|繁體|中文|traditional/i.test(lang)) return 'zh'
156 if (/en|english/i.test(lang)) return 'en'
157 } catch {}
158 return 'zh'
159}
160
161async function configDir($: EngineInterface): Promise<string> {
162 const dir = await $.env.get('CLAUDE_CONFIG_DIR')
163 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME')) || ''
164 return (dir || `${home}/.claude`).replace(/\\/g, '/')
165}
166
167async function readLocal($: EngineInterface): Promise<LocalCfg | null> {
168 const base = await configDir($)
169 const read = async (name: string) => JSON.parse(await $.fs.read(`${base}/${name}`)) as unknown
170 let cfg: LocalCfg | null = null
171 try {
172 cfg = parseLocal(await read(CONFIG_NAME))
173 } catch {}
174 if (!cfg) {
175 try {
176 cfg = parseLocal(await read(OLD_CONFIG_NAME), true)
177 } catch {}
178 }
179 if (cfg) {
180 statusBar = cfg.statusBar
181 avatarEntries = cfg.avatars
182 }
183 return cfg
184}
185
186async function health($: EngineInterface, cfg: LocalCfg): Promise<Health | null> {
187 try {
188 const win = await isWindows($)
189 const r = await $.process.run(healthArgv(cfg, await $.session.id(), win), { timeoutMs: 5000 })
190 return r.exitCode === 0 ? parseHealth(r.stdout) : null
191 } catch {
192 return null
193 }
194}
195
196// Writes this session's relay state for statusline tools (statusFields), when it changes and at least daily so the
197// router's sweep of week-old files never takes a live session's; then draws the plugin status line, only when the
198// config asks for it ("statusBar": true).
199async function showStatus($: EngineInterface, text: string, fields: [string, string]): Promise<void> {
200 // the session id is asked each time: /clear and an in-process resume go on under a new one without session.start
201 statusDir ??= await configDir($)
202 const file = statusFile(statusDir, await $.session.id(), fields)
203 if (file && (`${file.path}\n${file.line}` !== writtenStatus || Date.now() - writtenStatusAt > 86400000)) {
204 try {
205 await $.fs.write(file.path, file.line)
206 writtenStatus = `${file.path}\n${file.line}`
207 writtenStatusAt = Date.now()
208 } catch {}
209 }
210 const shown = statusBar ? text : undefined
211 if (shown === shownStatus) return
212 shownStatus = shown
213 $.ui.status(shown)
214}
215
216// One avatar view from what is wanted: the pixels from ffmpeg as cells, or a view that says why there are none
217async function makeAvatar($: EngineInterface, want: AvatarWant): Promise<AvatarView> {
218 const view: AvatarView = { ...want, cells: null, error: null }
219 if (!want.entry) return { ...view, error: 'unset' }
220 if (want.cols < 1) return view
221 const key = JSON.stringify([want.entry, want.mode, want.cols, want.rows])
222 const known = avatarMemo.get(key)
223 if (known) return { ...view, cells: known }
224 try {
225 const dir = await configDir($)
226 const out = avatarScratch(dir, await $.session.id())
227 await $.fs.write(out, '') // makes the folder ffmpeg writes into
228 const run = await $.process.run(avatarArgv(avatarSource(dir, want.entry.image), out, want.mode, want.entry.head, want.cols, want.rows), { timeoutMs: 20000 })
229 if (run.exitCode !== 0) return { ...view, error: 'failed' }
230 const rgba = base64ToBytes((await $.fs.read(out, { as: 'bytes' })).base64)
231 await $.fs.write(out, '').catch(() => undefined) // the pixels are held now; one empty file per window stays
232 if (rgba.length !== avatarBytes(want.cols, want.rows)) return { ...view, error: 'failed' }
233 const cells = rasterCells(rgba, want.cols, want.rows)
234 avatarMemo.set(key, cells)
235 if (avatarMemo.size > AVATAR_MEMO_SIZE) avatarMemo.delete(avatarMemo.keys().next().value!)
236 return { ...view, cells }
237 } catch {
238 return { ...view, error: 'failed' }
239 }
240}
241
242// Brings the avatar view up to what is wanted, one conversion at a time. A result is kept only if the wish did not move
243// while ffmpeg ran, and the loop then goes round for the new wish; the pass limit is only a stop for a loop that cannot end.
244async function pumpAvatar($: EngineInterface): Promise<void> {
245 if (avatarBusy) return
246 avatarBusy = true
247 try {
248 for (let pass = 0; pass < 8; pass++) {
249 const have = await read($, avatarView)
250 if (avatarMade(have, avatarWanted(avatarAim, avatarEntries))) return
251 const view = await makeAvatar($, avatarWanted(avatarAim, avatarEntries))
252 if (avatarMade(view, avatarWanted(avatarAim, avatarEntries))) await update($, avatarView, () => view)
253 }
254 } catch {
255 } finally {
256 avatarBusy = false
257 }
258}
259
260// Points the avatar pane at the persona this window talks to now. Until the pane has drawn once there is no body to
261// convert for, and its first draw starts the conversion.
262function followAvatar($: EngineInterface, h: Health | null, foreign = false): void {
263 const name = avatarName(h, foreign)
264 avatarAim = { ...avatarAim, name, label: avatarLabel(name, h?.providers ?? {}) }
265 if (avatarAim.body) void pumpAvatar($)
266}
267
268// The pane's draw is what tells the body its picture goes in
269function sizeAvatar($: EngineInterface, props: { bodyColumns: number; placement: 'dock' | 'inline'; scroll: { bodyRows: number } }): void {
270 avatarAim = { ...avatarAim, body: { placement: props.placement, columns: props.bodyColumns, rows: props.scroll.bodyRows } }
271 void pumpAvatar($)
272}
273
274// A reopened pane tries again: the image may have changed, or ffmpeg got onto PATH
275async function openAvatar($: EngineInterface): Promise<UiOpenResult> {
276 avatarMemo.clear()
277 await update($, avatarView, () => AVATAR_EMPTY)
278 return $.ui.open(AVATAR_OPEN)
279}
280
281async function closeAvatar($: EngineInterface): Promise<void> {
282 avatarAim = { ...avatarAim, body: null }
283 await $.ui.close({ id: AVATAR_PANE }).catch(() => undefined)
284}
285
286// The "avatarPane" switch every window shares: turned on (or found on at the start) it opens the pane here, turned off it
287// closes it, within a tick of the change. Unchanged, it leaves alone a pane /provider avatar opened or closed.
288async function followAvatarSwitch($: EngineInterface, cfg: LocalCfg): Promise<void> {
289 const on = cfg.avatarPane && Object.keys(cfg.avatars).length > 0
290 if (on === avatarSwitch) return
291 const was = avatarSwitch
292 avatarSwitch = on
293 if (on) await openAvatar($).catch(() => undefined)
294 else if (was) await closeAvatar($)
295}
296
297// /provider avatar closes the pane when it is drawn and opens it otherwise; a command is the person asking, so it is placed at any width
298async function toggleAvatar($: EngineInterface, lang: 'zh' | 'en'): Promise<string> {
299 const en = lang === 'en'
300 if ((await $.ui.panes()).some((p) => p.id === AVATAR_PANE && p.isPlaced)) {
301 await closeAvatar($)
302 return en ? 'Avatar pane closed.' : '頭像面板已關閉。'
303 }
304 const opened = await openAvatar($)
305 if (opened.isPlaced) return en ? 'Avatar pane open.' : '頭像面板已開啟。'
306 return en ? `Avatar pane is open but not drawn yet: ${opened.reason}` : `頭像面板已開啟,但還沒畫出來:${opened.reason}`
307}
308
309async function syncPanelData($: EngineInterface, cfg: LocalCfg, h: Health | null, lang: 'zh' | 'en'): Promise<void> {
310 try {
311 const ui = await read($, panelUi)
312 if (!ui.open) return
313 const snap = buildPanelData(h, cfg.port, direct, lang, Date.now(), cfg.outdated)
314 await update($, panelData, () => snap)
315 } catch {}
316}
317
318async function setPanelNote($: EngineInterface, note: PanelNote | undefined): Promise<void> {
319 try {
320 await update($, panelUi, ({ confirmingClaude: _c, confirmingProxy: _p, note: _n, ...u }) => (note ? { ...u, confirmingClaude: false, note } : { ...u, confirmingClaude: false }))
321 } catch {}
322}
323
324// Opened fresh each time: no note or open confirmation from last time, the shared settings folded away
325async function openPanel($: EngineInterface, cfg: LocalCfg, h: Health | null, lang: 'zh' | 'en'): Promise<void> {
326 await update($, panelData, () => buildPanelData(h, cfg.port, direct, lang, Date.now(), cfg.outdated))
327 await update($, panelUi, ({ note: _n, ...u }) => ({ ...u, open: true, confirmingClaude: false, confirmingProxy: false, showSettings: false }))
328}
329
330// Our router answered: it is the one source of what providers exist
331async function ours($: EngineInterface, cfg: LocalCfg, h: Health, lang: 'zh' | 'en'): Promise<void> {
332 startFailed = false
333 await syncProviders($, h.providers)
334 await syncPanelData($, cfg, h, lang)
335 followAvatar($, h)
336}
337
338// Keeps the router alive; when it cannot be brought back, this session talks to
339// Anthropic directly until the router answers again.
340async function tick($: EngineInterface): Promise<void> {
341 if (ticking) return
342 ticking = true
343 try {
344 const cfg = await readLocal($)
345 if (!cfg) return
346 await followAvatarSwitch($, cfg)
347 const lang = await detectLang($)
348 const seen = await health($, cfg)
349 // Another config dir's router on our port is not ours: never route through it, same as a router that is down
350 const other = foreignConfigDir(seen, await configDir($), await isWindows($))
351 if (other) {
352 if (!direct && (await $.env.get('ANTHROPIC_BASE_URL')) === routerUrl(cfg)) {
353 direct = true
354 await $.env.set('ANTHROPIC_BASE_URL', undefined)
355 $.ui.toast(lang === 'en' ? `Port ${cfg.port} belongs to another relay (${other}); connected directly to Claude` : `連接埠 ${cfg.port} 屬於另一個轉發站(${other}),主線直連 Claude`)
356 }
357 await showStatus($, foreignStatusText(other, lang), statusFields(null, direct, true))
358 followAvatar($, null, true)
359 return
360 }
361 const h = seen
362 if (cfg.outdated && !outdatedTold) {
363 outdatedTold = true
364 $.ui.toast(outdatedText(lang))
365 }
366 // `plugin update` leaves the old router running, and the hook only starts one when none answers; replace it here,
367 // but only while it answers nothing (setup.mjs restart --if-idle), so no other session loses a reply. A router from
368 // 0.6.0 or earlier cannot report that, so it is only pointed out: /provider restart is the person's call. With an
369 // older config nothing is replaced: the router that answers may be the last one that still reads it.
370 const stale = !cfg.outdated && routerIsStale(h, await myVersion($))
371 if (!stale) staleBusy = false
372 else if (h && typeof h.inflight !== 'number') {
373 if (!staleBusy) $.ui.toast(staleStatusText(lang))
374 staleBusy = true
375 } else if (Date.now() - lastStaleTry > 60000) {
376 lastStaleTry = Date.now()
377 const r = await $.process.run([cfg.nodePath, setupScript($.plugin.root), 'restart', '--if-stale', '--if-idle'], { timeoutMs: 30000 }).catch(() => null)
378 staleBusy = r?.exitCode === 3
379 if (r?.exitCode === 0) {
380 $.ui.toast(lang === 'en' ? 'The relay was restarted to run this plugin version' : '轉發站已重啟,改跑這個外掛版本的程式')
381 return // the next tick reads the new router
382 }
383 }
384 if (h) {
385 if (direct) {
386 direct = false
387 await $.env.set('ANTHROPIC_BASE_URL', routerUrl(cfg))
388 $.ui.toast(lang === 'en' ? 'Relay recovered; routing through local relay' : '轉發站恢復,主線重新經由轉發站')
389 }
390 const note = cfg.outdated ? outdatedText(lang) : staleBusy ? staleStatusText(lang) : ''
391 await showStatus($, note ? `${statusText(h, false, lang)} · ${note}` : statusText(h, false, lang), statusFields(h, false))
392 await ours($, cfg, h, lang)
393 return
394 }
395 // This version's router does not read an older config, so none is started for one
396 if (!cfg.outdated && Date.now() - lastStart > 15000) {
397 lastStart = Date.now()
398 const win = await isWindows($)
399 const dir = await configDir($)
400 await $.process.run(startArgv(cfg, routerScript($.plugin.root), dir, win), { timeoutMs: 15000 }).catch(() => undefined)
401 const up = await $.process.run(healthWaitArgv(cfg, await $.session.id(), win), { timeoutMs: 20000 }).catch(() => null)
402 const h2 = up && up.exitCode === 0 ? parseHealth(up.stdout) : null
403 if (h2) {
404 await showStatus($, statusText(h2, false, lang), statusFields(h2, false))
405 await ours($, cfg, h2, lang)
406 return
407 }
408 if (!startFailed) {
409 startFailed = true
410 $.ui.toast(startFailedText(dir, lang))
411 }
412 }
413 if (!direct && (await $.env.get('ANTHROPIC_BASE_URL')) === routerUrl(cfg)) {
414 direct = true
415 await $.env.set('ANTHROPIC_BASE_URL', undefined)
416 }
417 await showStatus($, cfg.outdated ? `${statusText(null, direct, lang)} · ${outdatedText(lang)}` : statusText(null, direct, lang), statusFields(null, direct))
418 await syncPanelData($, cfg, null, lang)
419 followAvatar($, null)
420 } finally {
421 ticking = false
422 }
423}
424
425// After a model-relay:<name> subagent's turn: a router that reports what reached it says whether its model did
426async function checkReached($: EngineInterface, p: { name: string; model: string; since: number }): Promise<void> {
427 const cfg = await readLocal($)
428 if (!cfg) return
429 if (reachedRelay(await health($, cfg), p.model, p.since) === false) $.ui.toast(missedRelayText(p.name, p.model, await detectLang($)))
430}
431
432function panelActions($: EngineInterface): PanelActions {
433 const good = (text: string): PanelNote => ({ text, ok: true })
434 const bad = (text: string): PanelNote => ({ text, ok: false })
435 const run = (work: () => Promise<PanelNote | undefined>) => {
436 void work()
437 .catch((err) => bad(String(err)))
438 .then(async (note) => {
439 const cfg = await readLocal($)
440 if (cfg) {
441 const lang = await detectLang($)
442 const h = await health($, cfg)
443 if (h) {
444 await showStatus($, statusText(h, false, lang), statusFields(h, false))
445 followAvatar($, h)
446 }
447 await syncPanelData($, cfg, h, lang)
448 }
449 await setPanelNote($, note)
450 })
451 }
452 const close = () => update($, panelUi, (u) => ({ ...u, open: false, confirmingClaude: false, confirmingProxy: false }))
453 return {
454 // A switch that took is the panel's job done: it closes, and a toast (and the status line, when on) says where this window goes now
455 switchTo: (target) =>
456 run(async () => {
457 const cfg = await readLocal($)
458 const lang = await detectLang($)
459 if (!cfg) return bad(lang === 'en' ? 'Config missing.' : '設定檔不存在。')
460 const win = await isWindows($)
461 const r = await $.process.run(sessionArgv(cfg, await $.session.id(), target, win), { timeoutMs: 8000 }).catch(() => null)
462 if (!r || r.exitCode !== 0 || !r.stdout.includes('"ok":true')) {
463 return bad(lang === 'en' ? 'Relay did not respond; nothing changed.' : '轉發站沒有回應,沒有改變。')
464 }
465 const h = await health($, cfg)
466 await close()
467 $.ui.toast(switchedText(target, h && has(h.providers, target) ? h.providers[target]! : target, lang))
468 return undefined
469 }),
470 askClaude: (ask) => {
471 void update($, panelUi, ({ note: _n, ...u }) => ({ ...u, confirmingClaude: ask }))
472 },
473 // The rows show what each test came to, so a test that ran needs no note of its own
474 ping: () =>
475 run(async () => {
476 const ui = await read($, panelUi)
477 if (ui.pinging) return undefined
478 const cfg = await readLocal($)
479 const lang = await detectLang($)
480 if (!cfg) return undefined
481 await update($, panelUi, ({ note: _n, ...u }) => ({ ...u, pinging: true }))
482 try {
483 const win = await isWindows($)
484 const r = await $.process.run(pingArgv(cfg, '', win), { timeoutMs: (PING_LIMIT_S + 5) * 1000 }).catch(() => null)
485 if (!r || r.exitCode !== 0) return bad(lang === 'en' ? 'Test failed: relay unreachable.' : '測試失敗:轉發站未回應。')
486 return undefined
487 } finally {
488 await update($, panelUi, (u) => ({ ...u, pinging: false }))
489 }
490 }),
491 probe: () =>
492 run(async () => {
493 const cfg = await readLocal($)
494 const lang = await detectLang($)
495 if (!cfg) return undefined
496 const win = await isWindows($)
497 await $.process.run(probeArgv(cfg, win), { timeoutMs: 5000 }).catch(() => null)
498 return good(lang === 'en' ? '✓ Cooldown reset; the next new session tries Claude.' : '✓ 已重置冷卻,下一個新對話會先試 Claude。')
499 }),
500 settings: (show) => {
501 void update($, panelUi, ({ note: _n, ...u }) => ({ ...u, showSettings: show, confirmingProxy: false }))
502 },
503 askProxy: (ask) => {
504 void update($, panelUi, ({ note: _n, ...u }) => ({ ...u, confirmingProxy: ask }))
505 },
506 toggle: (which, on) =>
507 run(async () => {
508 const cfg = await readLocal($)
509 const lang = await detectLang($)
510 if (!cfg) return bad(lang === 'en' ? 'Config missing.' : '設定檔不存在。')
511 const argv = toggleArgv(cfg, setupScript($.plugin.root), which, on)
512 // The proxy switch restarts the router and tests the certificate, which takes a few seconds
513 const r = await $.process.run(argv, { timeoutMs: which === 'proxy' ? 60000 : 15000 }).catch(() => null)
514 if (!r || r.exitCode !== 0) {
515 const why = (r?.stderr || r?.stdout || '').trim().split(/\r?\n/).pop() ?? ''
516 return bad(lang === 'en' ? `Not changed: ${why || 'setup command failed'}` : `沒有變更:${why || '設定指令失敗'}`)
517 }
518 if (which === 'proxy') {
519 return good(on
520 ? (lang === 'en' ? '✓ Remote Control on; new Claude Code sessions use it.' : '✓ 已開啟手機遙控;開新的 Claude Code 對話後生效。')
521 : (lang === 'en' ? '✓ Remote Control off; sessions already in proxy mode lose their connection.' : '✓ 已關閉手機遙控;已在代理模式執行的對話會斷線。'))
522 }
523 const name = which === 'failover' ? (lang === 'en' ? '429 failover' : '429 備援') : lang === 'en' ? 'Retry' : '失敗重試'
524 return good(lang === 'en' ? `✓ ${name} ${on ? 'on' : 'off'} from the next request.` : `✓ ${name}已${on ? '開啟' : '關閉'},下一個請求起生效。`)
525 }),
526 close: () => {
527 void close()
528 },
529 }
530}
531
532export const register: Register = (on) => {
533 on('session.start', async ($, e, next) => {
534 await syncProviders($, {}) // /provider exists even before the router answers; tick adds the providers
535 await tick($)
536 $.clock.every(10000, () => {
537 void tick($).catch(() => {})
538 })
539 return next(e)
540 })
541
542 on('agent.spawn', async ($, e, next) => {
543 if (e.fork || e.resume) return next(e)
544 const relay = relayAgent(e.subagentType, knownProviders)
545 if (!relay) return next(e)
546 const since = Date.now()
547 const r = await next({ ...e, model: relay.model })
548 try {
549 if (spawnedElsewhere(relay.model, r.model)) $.ui.toast(elsewhereText(relay.name, relay.model, r.model ?? '', await detectLang($)))
550 else if (r.agentId) {
551 pending.set(r.agentId, { ...relay, since })
552 if (pending.size > 50) pending.delete(pending.keys().next().value!)
553 }
554 } catch {}
555 return r
556 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'model-relay agent.spawn failed' }))
557
558 on('turn.complete', ($, e, next) => {
559 const p = e.agentId ? pending.get(e.agentId) : undefined
560 if (p && e.agentId) {
561 pending.delete(e.agentId)
562 if (!e.isAborted) void checkReached($, p).catch(() => {})
563 }
564 return next(e)
565 })
566
567 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
568 try {
569 const ui = await read($, panelUi)
570 const data = ui.open && !e.props.hasSurvey ? await read($, panelData) : null
571 return data
572 ? panelTree($.ui.resolve(e), { ...data, ...ui, columns: e.props.bodyColumns }, panelActions($))
573 : next(e)
574 } catch {
575 return next(e)
576 }
577 })
578
579 on('ui.render', { component: 'Pane', requestId: AVATAR_PANE }, async ($, e, next) => {
580 try {
581 sizeAvatar($, e.props)
582 return avatarTree($.ui.resolve(e), await read($, avatarView), e.props.bodyColumns, await detectLang($))
583 } catch {
584 return next(e)
585 }
586 })
587
588 on('command.run', { command: 'provider' }, async ($, e) => {
589 const cfg = await readLocal($)
590 const lang = await detectLang($)
591 const arg = e.args.trim().toLowerCase()
592 // First-run setup works without a config, and without knowing where the plugin was installed
593 const first = arg.split(/\s+/)[0] ?? ''
594 if (first === 'setup') {
595 const goal = e.args.trim().slice('setup'.length).trim()
596 void $.prompt.submit({ text: setupBrief(setupScript($.plugin.root), cfg?.nodePath ?? 'node', goal, !!cfg, lang) })
597 return { text: lang === 'en' ? 'Handing setup to Claude…' : '已交給 Claude 協助設定…' }
598 }
599 if (first === 'init' || first === 'doctor' || first === 'presets' || first === 'restart') {
600 const r = await $.process.run([cfg?.nodePath ?? 'node', setupScript($.plugin.root), ...setupCommandArgs(first, arg)], { timeoutMs: 60000 }).catch(() => null)
601 const out = ((r?.stdout || '') + (r?.stderr || '')).trim()
602 return { text: out || (lang === 'en' ? 'Setup command did not respond.' : '設定指令沒有回應。') }
603 }
604 if (arg === 'help') return { text: helpText(Object.keys(knownProviders), lang) }
605 if (!cfg) {
606 return {
607 text: lang === 'en'
608 ? 'There is no model-relay.json yet. Run /provider setup to let Claude walk you through it, or /provider init to create an empty one.'
609 : '還沒有 model-relay.json。執行 /provider setup 讓 Claude 帶你設定,或 /provider init 建立一個空的設定檔。',
610 }
611 }
612 const win = await isWindows($)
613
614 // Bare `/provider` opens the panel and says only that; `panel` toggles it
615 if (arg === '' || arg === 'panel') {
616 if (arg === 'panel' && (await read($, panelUi)).open) {
617 await update($, panelUi, (u) => ({ ...u, open: false, confirmingClaude: false, confirmingProxy: false }))
618 return { text: lang === 'en' ? 'Panel closed.' : '面板已關閉。' }
619 }
620 await openPanel($, cfg, await health($, cfg), lang)
621 const opened = lang === 'en' ? 'Panel open above the prompt (all commands: /provider help)' : '面板已開在輸入框上方(所有指令:/provider help)'
622 return { text: cfg.outdated ? `${outdatedText(lang)}\n${opened}` : opened }
623 }
624
625 if (arg === 'status') {
626 return { text: `${cfg.outdated ? `${outdatedText(lang)}\n\n` : ''}${providerReport(await health($, cfg), Date.now(), lang)}` }
627 }
628
629 if (arg === 'avatar') return { text: await toggleAvatar($, lang) }
630
631 if (arg === 'ping') {
632 await $.process.run(pingArgv(cfg, '', win), { timeoutMs: (PING_LIMIT_S + 5) * 1000 }).catch(() => null)
633 const h = await health($, cfg)
634 await syncPanelData($, cfg, h, lang)
635 return { text: pingReport(h, lang) }
636 }
637
638 if (arg === 'probe') {
639 const r = await $.process.run(probeArgv(cfg, win), { timeoutMs: 5000 }).catch(() => null)
640 await syncPanelData($, cfg, await health($, cfg), lang)
641 if (!r || r.exitCode !== 0) return { text: lang === 'en' ? 'Relay did not respond; nothing changed.' : '轉發站沒有回應,沒有改變。' }
642 return { text: lang === 'en' ? '✓ 429 cooldown reset; the next new session tries Claude.' : '✓ 已重置 429 冷卻,下一個新對話會先試 Claude。' }
643 }
644
645 const parts = arg.split(/\s+/).filter(Boolean)
646 const head = parts[0] ?? ''
647 // /provider avatar on|off: the switch every window shares, written to the config and followed here at once
648 if (head === 'avatar') {
649 const r = await $.process.run(setupCliArgv(cfg, setupScript($.plugin.root), ['set', 'avatarPane', parts[1] ?? '']), { timeoutMs: 15000 }).catch(() => null)
650 const out = ((r?.stdout || '') + (r?.stderr || '')).trim()
651 const cfgNext = (await readLocal($)) ?? cfg
652 await followAvatarSwitch($, cfgNext)
653 const none = cfgNext.avatarPane && Object.keys(cfgNext.avatars).length === 0
654 const hint = none ? (lang === 'en' ? '\nThere is no picture to show yet: add "avatars" to model-relay.json (see the README).' : '\n還沒有可顯示的頭像:在 model-relay.json 加上 "avatars"(見 README)。') : ''
655 return { text: (out || (lang === 'en' ? 'Setup command did not respond.' : '設定指令沒有回應。')) + hint }
656 }
657 if (head === 'alias' || head === 'failover' || head === 'retry' || head === 'rc' || head === 'proxy') {
658 const script = setupScript($.plugin.root)
659 let cliArgs: string[]
660 if (head === 'alias') {
661 cliArgs = ['alias', ...parts.slice(1)]
662 } else if (head === 'failover') {
663 const val = parts[1] ?? ''
664 const isBool = ['on', 'off', 'true', 'false', '1', '0'].includes(val)
665 cliArgs = val ? ['set', isBool ? 'failover.enabled' : 'failover.chain', val] : ['providers']
666 } else if (head === 'retry') {
667 cliArgs = parts[1] ? ['set', 'retry.enabled', parts[1]] : ['providers']
668 } else {
669 const val = parts[1] ?? 'status'
670 const sub = ['on', 'enable', 'true', '1'].includes(val)
671 ? 'enable'
672 : ['off', 'disable', 'false', '0'].includes(val)
673 ? 'disable'
674 : val
675 cliArgs = sub === 'enable' || sub === 'disable' ? ['proxy', sub, '--go'] : ['proxy', sub]
676 }
677 const r = await $.process.run(setupCliArgv(cfg, script, cliArgs), { timeoutMs: head === 'rc' || head === 'proxy' ? 60000 : 15000 }).catch(() => null)
678 const out = ((r?.stdout || '') + (r?.stderr || '')).trim()
679 const cfgNext = (await readLocal($)) ?? cfg
680 const h = await health($, cfgNext)
681 if (h) {
682 await showStatus($, statusText(h, false, lang), statusFields(h, false))
683 followAvatar($, h)
684 }
685 await syncPanelData($, cfgNext, h, lang)
686 return { text: out || (lang === 'en' ? 'Setup command did not respond.' : '設定指令沒有回應。') }
687 }
688
689 const h0 = await health($, cfg)
690 const providers = h0?.providers ?? {}
691 if (arg === 'claude' || has(providers, arg)) {
692 const r = await $.process.run(sessionArgv(cfg, await $.session.id(), arg, win), { timeoutMs: 8000 }).catch(() => null)
693 if (!r || r.exitCode !== 0 || !r.stdout.includes('"ok":true')) {
694 return { text: lang === 'en' ? 'Relay did not respond; nothing changed.' : '轉發站沒有回應,沒有改變。' }
695 }
696 const h = await health($, cfg)
697 if (h) {
698 await showStatus($, statusText(h, false, lang), statusFields(h, false))
699 followAvatar($, h)
700 }
701 await syncPanelData($, cfg, h, lang)
702 return { text: switchedText(arg, providers[arg] ?? arg, lang) }
703 }
704 const names = [...Object.keys(providers), 'claude'].join(lang === 'en' ? ', ' : '、')
705 return {
706 text: lang === 'en'
707 ? `No provider or command "${arg}". Switch with /provider <${names}>; all commands: /provider help`
708 : `沒有「${arg}」這個供應商或指令。切換用 /provider <${names}>;所有指令:/provider help`,
709 }
710 }).catch(() => ({ text: '/provider 執行失敗,設定未變更。' }))
711}
712hooks/logic.ts 1002 lines1import type { AvatarEntry, AvatarMode, AvatarView, PanelData, PingStat, ProviderStat } from '../types'
2
3// What the hook takes from the config file: where the router listens and which node runs it. Providers, aliases and the
4// failover chain come from the router's /__health only, so the plugin never second-guesses how the router reads a config.
5// outdated: the file is from an older version, which this version's router does not read (no "format": 2, or the older
6// file name); the hook then starts and replaces no router, so a router that still reads it keeps working until migrate
7// statusBar: also draw the relay state in Claude Code's plugin status line ("statusBar": true); off by default, since
8// the per-session status file (statusFields) lets a statusline tool show it instead
9// avatars: the pictures of the avatar pane, by persona (parseAvatars); the router ignores the key
10// avatarPane: open the avatar pane in every window ("avatarPane": true); off by default, so pictures in the config show
11// nothing until it is turned on (/provider avatar on)
12export interface LocalCfg {
13 port: number
14 nodePath: string
15 outdated: boolean
16 statusBar: boolean
17 avatars: Record<string, AvatarEntry>
18 avatarPane: boolean
19}
20
21export const CONFIG_NAME = 'model-relay.json'
22// legacy:begin the file name of 0.6.0 and earlier, read for its port and node only
23export const OLD_CONFIG_NAME = 'custom-agent.json'
24// legacy:end
25
26export function parseLocal(raw: unknown, oldName = false): LocalCfg | null {
27 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
28 const r = raw as Record<string, unknown>
29 const port = typeof r.port === 'number' && Number.isInteger(r.port) && r.port > 1024 && r.port < 65536 ? r.port : 8950
30 const nodePath = typeof r.nodePath === 'string' && r.nodePath.length > 0 ? r.nodePath : 'node'
31 return { port, nodePath, outdated: oldName || r.format !== 2, statusBar: r.statusBar === true, avatars: parseAvatars(r.avatars), avatarPane: r.avatarPane === true }
32}
33
34// The agent fields a provider's "agent" object in the config may set, with the shape each must have; `name` and `model`
35// are not among them: the type is named after the provider and runs on its model. Claude Code validates the rest.
36const isStr = (v: unknown) => typeof v === 'string' && v.trim().length > 0
37const isStrList = (v: unknown) => Array.isArray(v) && v.every((x) => typeof x === 'string')
38const AGENT_FIELDS: Record<string, (v: unknown) => boolean> = {
39 description: isStr,
40 prompt: isStr,
41 tools: isStrList,
42 disallowedTools: isStrList,
43 skills: isStrList,
44 effort: (v) => isStr(v) || (typeof v === 'number' && Number.isInteger(v) && v > 0),
45 permissionMode: isStr,
46 maxTurns: (v) => typeof v === 'number' && Number.isInteger(v) && v > 0,
47 autoCompactWindow: (v) => typeof v === 'number' && Number.isInteger(v) && v > 0,
48 initialPrompt: isStr,
49 memory: (v) => v === 'user' || v === 'project' || v === 'local',
50 isolation: (v) => v === 'worktree' || v === 'remote',
51 background: (v) => v === true,
52 omitClaudeMd: (v) => v === true,
53 mcpServers: (v) => Array.isArray(v) && v.every((x) => typeof x === 'string' || (typeof x === 'object' && x !== null && !Array.isArray(x))),
54 hooks: (v) => typeof v === 'object' && v !== null && !Array.isArray(v),
55}
56
57export type AgentOptions = Record<string, unknown>
58
59// Each provider's "agent" object from the config, by name, keeping only the fields above in the right shape
60export function providerAgentOptions(raw: unknown): Record<string, AgentOptions> {
61 const out: Record<string, AgentOptions> = {}
62 const providers = (raw as { providers?: unknown } | null)?.providers
63 if (typeof providers !== 'object' || providers === null || Array.isArray(providers)) return out
64 for (const [name, p] of Object.entries(providers as Record<string, unknown>)) {
65 const agent = (p as { agent?: unknown } | null)?.agent
66 if (typeof agent !== 'object' || agent === null || Array.isArray(agent)) continue
67 const kept: AgentOptions = {}
68 for (const [k, v] of Object.entries(agent as Record<string, unknown>)) {
69 if (has(AGENT_FIELDS, k) && AGENT_FIELDS[k]!(v)) kept[k] = v
70 }
71 if (Object.keys(kept).length) out[name] = kept
72 }
73 return out
74}
75
76export function outdatedText(lang: 'zh' | 'en' = 'zh'): string {
77 return lang === 'en'
78 ? 'Config from an older version, not read by this one: run /provider setup upgrade my model-relay config'
79 : '設定檔是舊版格式,這一版不讀它:執行 /provider setup 升級我的 model-relay 設定'
80}
81
82// A name typed by a person is looked up as an own key only: "constructor" is not a provider
83export const has = (o: Record<string, unknown>, k: string) => Object.hasOwn(o, k)
84
85const cells = (ch: string) => ((ch.codePointAt(0) ?? 0) >= 0x1100 ? 2 : 1)
86
87// Cuts text to a terminal width in cells (CJK counts two), ending in … when it had to cut
88export function fit(s: string, width: number): string {
89 const text = s.replace(/\s+/g, ' ').trim()
90 let used = 0
91 let out = ''
92 for (const ch of text) {
93 const w = cells(ch)
94 if (used + w > width - 1) return `${out}…`
95 used += w
96 out += ch
97 }
98 return out
99}
100
101// ---- local router ----
102
103export function curlBin(isWin = true): string {
104 return isWin ? 'C:/Windows/System32/curl.exe' : 'curl'
105}
106
107export const routerUrl = (cfg: LocalCfg) => `http://127.0.0.1:${cfg.port}`
108
109const healthUrl = (cfg: LocalCfg, sid: string) => `${routerUrl(cfg)}/__health?sid=${encodeURIComponent(sid)}`
110
111export function healthArgv(cfg: LocalCfg, sid: string, isWin = true): string[] {
112 return [curlBin(isWin), '-s', '-m', '2', healthUrl(cfg, sid)]
113}
114
115export function healthWaitArgv(cfg: LocalCfg, sid: string, isWin = true): string[] {
116 return [curlBin(isWin), '-s', '-m', '2', '--retry', '6', '--retry-connrefused', '--retry-delay', '1', healthUrl(cfg, sid)]
117}
118
119export function sessionArgv(cfg: LocalCfg, sid: string, on: string, isWin = true): string[] {
120 return [curlBin(isWin), '-s', '-m', '3', '-X', 'POST', '-H', 'content-type: application/json', '-d', JSON.stringify({ sid, on }), `${routerUrl(cfg)}/__session`]
121}
122
123// The router tests each provider for up to 15 s, after waiting up to 10 s for a local one it starts itself
124export const PING_LIMIT_S = 30
125
126export function pingArgv(cfg: LocalCfg, provider = '', isWin = true): string[] {
127 return [curlBin(isWin), '-s', '-m', String(PING_LIMIT_S), '-X', 'POST', '-H', 'content-type: application/json', '-d', JSON.stringify({ provider }), `${routerUrl(cfg)}/__ping`]
128}
129
130export function probeArgv(cfg: LocalCfg, isWin = true): string[] {
131 return [curlBin(isWin), '-s', '-m', '3', '-X', 'POST', `${routerUrl(cfg)}/__probe`]
132}
133
134const pluginBase = (pluginRoot: string) => pluginRoot.replace(/[\\/]\.claude-plugin[\\/]?$/, '').replace(/\\/g, '/')
135
136export const routerScript = (pluginRoot: string) => `${pluginBase(pluginRoot)}/router/router.js`
137
138export const setupScript = (pluginRoot: string) => `${pluginBase(pluginRoot)}/tools/setup.mjs`
139
140export type Toggle = 'failover' | 'retry' | 'proxy'
141
142// The panel and `/provider` subcommands change settings through the same CLI a person would use
143export function toggleArgv(cfg: LocalCfg, script: string, which: Toggle, on: boolean): string[] {
144 if (which === 'proxy') return [cfg.nodePath, script, 'proxy', on ? 'enable' : 'disable', '--go']
145 return [cfg.nodePath, script, 'set', which === 'failover' ? 'failover.enabled' : 'retry.enabled', on ? 'on' : 'off']
146}
147
148// The turn /provider setup hands to the model: where the CLI is, what the person asked for, and the rules that keep keys and settings safe.
149export function setupBrief(script: string, nodePath: string, goal: string, hasCfg: boolean, lang: 'zh' | 'en'): string {
150 const cli = `"${nodePath}" "${script}"`
151 return [
152 lang === 'en'
153 ? 'Help me set up the model-relay plugin for Claude Code. Reply in English.'
154 : '請幫我設定 Claude Code 的 model-relay 外掛,用繁體中文(台灣)回覆。',
155 goal ? `What I want: ${goal}` : 'What I want: a working setup with one provider; ask me which provider if it is not clear.',
156 '',
157 `The setup CLI is ${cli} (run it by this absolute path; it is not in the current folder). Run it with no arguments for usage, and "presets" for the built-in providers.`,
158 hasCfg
159 ? `A config already exists. Start with ${cli} doctor --json and ${cli} providers to see the current state; a finding's "fix" is the arguments to run this CLI with, and "needsConsent" means ask me first.`
160 : `There is no config yet. Start with ${cli} presets, then create it with "init --fix" and "add <name> --preset <preset> --model <model id> --token env:<VAR>" (or --url for any other Anthropic-compatible address). Ask me which model I want; there is no default.`,
161 '',
162 'Rules:',
163 '- Never ask me to paste an API key into this chat, and never print one. Use "env:VAR_NAME" tokens; tell me which variable to set, how to make it permanent on my OS, and that Claude Code must be restarted to see it.',
164 '- Commands that accept --go (migrate, remove, proxy): run without --go first, show me the result, and add --go only after I agree.',
165 '- Tell me before anything changes ~/.claude/settings.json (init --fix, doctor --fix, alias, proxy) and wait for my yes.',
166 `- If doctor lists the "outdated" finding, my config is from an older version: follow ${script.replace(/tools\/setup\.mjs$/, 'UPGRADING.md')} step by step.`,
167 `- Finish with ${cli} doctor --ping and tell me in plain words what works, what does not, and what I still have to do.`,
168 ].join('\n')
169}
170
171export function setupCliArgv(cfg: LocalCfg, script: string, cliArgs: string[]): string[] {
172 return [cfg.nodePath, script, ...cliArgs]
173}
174
175// What /provider init|doctor|presets|restart runs. init is setting up, so it wires settings.json; doctor only looks until the person types --fix
176export function setupCommandArgs(first: string, arg: string): string[] {
177 const typed = arg.split(/\s+/)
178 if (first === 'init') return ['init', '--fix']
179 if (first === 'doctor') return ['doctor', ...['--fix', '--ping'].filter((flag) => typed.includes(flag))]
180 return [first]
181}
182
183// Detaches the router process on both Windows and POSIX platforms.
184export function startArgv(cfg: LocalCfg, script: string, configDir: string, isWin = true): string[] {
185 if (isWin) {
186 const q = (s: string) => `'${s.replace(/'/g, "''")}'`
187 return [
188 'powershell.exe',
189 '-NoProfile',
190 '-Command',
191 `Start-Process -WindowStyle Hidden -FilePath ${q(cfg.nodePath)} -ArgumentList @(${q(`"${script}"`)}, ${q(`"${configDir}"`)})`,
192 ]
193 }
194 const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
195 return ['sh', '-c', `nohup ${shQuote(cfg.nodePath)} ${shQuote(script)} ${shQuote(configDir)} >/dev/null 2>&1 &`]
196}
197
198export interface Health {
199 session: string // 'claude' or a configured provider key
200 failoverSince: number
201 nextClaudeTry: number
202 sessionsOnCustom?: number
203 providers: Record<string, string> // provider name -> model id; empty when none is usable
204 aliases: Record<string, string>
205 failoverProvider?: string // absent when failover has nobody to hand over to
206 failoverChain: string[]
207 configDirs?: string[] // the directory this router serves, as it resolves it and as it was given; absent on older routers
208 version?: string // the plugin version the router's code came from; absent on 0.6.0 and earlier
209 inflight?: number // model and passthrough requests being answered right now; absent on 0.6.0 and earlier
210 tunnels?: number
211 stats?: Record<string, ProviderStat> // the last real request per provider
212 ping?: Record<string, PingStat> // the last test per provider; absent on older routers
213 seenModels?: Record<string, number> // when each model id last reached the router; absent on older routers
214 unresolved: { provider: string; env: string }[] // providers skipped because their env:VAR is unset for the router
215 // What the router is doing right now; absent when it predates the switches
216 switches?: { failover: boolean; retry: boolean; proxy: boolean; proxyReady: boolean }
217}
218
219const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
220
221export function parseHealth(stdout: string): Health | null {
222 try {
223 const j = JSON.parse(stdout) as Record<string, unknown>
224 if (!isObj(j) || j.ok !== true) return null
225 const num = (v: unknown) => (typeof v === 'number' ? v : 0)
226 const str = (v: unknown) => (typeof v === 'string' && v.length > 0 ? v : '')
227 const lowerMap = (v: unknown, lowerValues: boolean) => {
228 const out: Record<string, string> = {}
229 if (isObj(v)) for (const [k, x] of Object.entries(v)) if (str(x)) out[k.toLowerCase()] = lowerValues ? str(x).toLowerCase() : str(x)
230 return out
231 }
232 const stats: Record<string, ProviderStat> = {}
233 if (isObj(j.stats)) {
234 for (const [k, v] of Object.entries(j.stats)) if (isObj(v)) stats[k.toLowerCase()] = { status: num(v.status), ms: num(v.ms), at: num(v.at) }
235 }
236 let ping: Record<string, PingStat> | undefined
237 if (isObj(j.ping)) {
238 ping = {}
239 for (const [k, v] of Object.entries(j.ping)) {
240 if (isObj(v) && str(v.verdict)) ping[k.toLowerCase()] = { verdict: str(v.verdict), status: num(v.status), firstByteMs: num(v.firstByteMs), at: num(v.at), detail: str(v.detail) }
241 }
242 }
243 let seenModels: Record<string, number> | undefined
244 if (isObj(j.seenModels)) {
245 seenModels = {}
246 for (const [k, v] of Object.entries(j.seenModels)) if (typeof v === 'number') seenModels[k.toLowerCase()] = v
247 }
248 const failoverProvider = str(j.failoverProvider).toLowerCase()
249 const failoverChain = Array.isArray(j.failoverChain) ? j.failoverChain.map(str).filter(Boolean).map((n) => n.toLowerCase()) : []
250 const configDirs = [j.configDir, j.configDirArg].map(str).filter(Boolean)
251 const conf = isObj(j.config) ? j.config : null
252 const px = isObj(j.proxy) ? j.proxy : null
253 const switches = conf && typeof conf.failoverEnabled === 'boolean' && typeof conf.retryEnabled === 'boolean' && typeof conf.proxyEnabled === 'boolean'
254 ? { failover: conf.failoverEnabled, retry: conf.retryEnabled, proxy: conf.proxyEnabled, proxyReady: px?.certificate === true }
255 : undefined
256 const unresolved = Array.isArray(j.unresolved)
257 ? j.unresolved.filter(isObj).map((u) => ({ provider: str(u.provider), env: str(u.env) })).filter((u) => u.provider)
258 : []
259 return {
260 session: str(j.session).toLowerCase() || 'claude',
261 failoverSince: num(j.failoverSince),
262 nextClaudeTry: num(j.nextClaudeTry),
263 sessionsOnCustom: num(j.sessionsOnCustom),
264 providers: lowerMap(j.providers, false),
265 aliases: lowerMap(j.aliases, true),
266 ...(failoverProvider ? { failoverProvider } : {}),
267 failoverChain,
268 ...(configDirs.length > 0 ? { configDirs } : {}),
269 ...(str(j.version) ? { version: str(j.version) } : {}),
270 ...(typeof j.inflight === 'number' ? { inflight: j.inflight } : {}),
271 ...(typeof j.tunnels === 'number' ? { tunnels: j.tunnels } : {}),
272 stats,
273 ...(ping ? { ping } : {}),
274 ...(seenModels ? { seenModels } : {}),
275 unresolved,
276 ...(switches ? { switches } : {}),
277 }
278 } catch {
279 return null
280 }
281}
282
283// One line per thing the person should act on: an older config, a provider the router skips
284export function healthHints(h: Health | null, lang: 'zh' | 'en', outdated = false): string[] {
285 const en = lang === 'en'
286 const out = outdated ? [outdatedText(lang)] : []
287 for (const u of h ? h.unresolved : []) {
288 out.push(en
289 ? `${u.provider} is skipped: ${u.env ? `the relay was started without ${u.env}` : 'its token "env:" names no variable'}`
290 : `${u.provider} 被略過:${u.env ? `轉發站啟動時沒有 ${u.env}` : '它的 token「env:」沒寫變數名稱'}`)
291 }
292 return out
293}
294
295export function buildPanelData(h: Health | null, port: number, direct: boolean, lang: 'zh' | 'en', now: number, outdated = false): PanelData {
296 return {
297 online: Boolean(h),
298 direct,
299 port,
300 session: h?.session ?? 'claude',
301 failoverProvider: h?.failoverProvider ?? null,
302 failoverSince: h?.failoverSince ?? 0,
303 nextClaudeTry: h?.nextClaudeTry ?? 0,
304 sessionsOnCustom: h?.sessionsOnCustom ?? 0,
305 providers: h?.providers ?? {},
306 aliases: h?.aliases ?? {},
307 failoverChain: h?.failoverChain ?? [],
308 switches: h?.switches ?? null,
309 stats: h?.stats ?? {},
310 ping: h?.ping ?? {},
311 hints: healthHints(h, lang, outdated),
312 lang,
313 now,
314 }
315}
316
317// The directory of a router that answers on our port but serves a different config dir; null when it is ours.
318// A router that reports no directory is an older relay and still counts as ours.
319export function foreignConfigDir(h: Health | null, myDir: string, isWin = true): string | null {
320 if (!h || !h.configDirs || h.configDirs.length === 0) return null
321 const key = (d: string) => {
322 const s = d.replace(/\\/g, '/').replace(/\/+$/, '')
323 return isWin ? s.toLowerCase() : s
324 }
325 return h.configDirs.some((d) => key(d) === key(myDir)) ? null : h.configDirs[0]!
326}
327
328// -1, 0 or 1 for dotted numeric versions; a missing part counts as 0
329export function compareVersions(a: string, b: string): number {
330 const pa = a.split('.').map((n) => parseInt(n, 10) || 0)
331 const pb = b.split('.').map((n) => parseInt(n, 10) || 0)
332 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
333 const d = (pa[i] ?? 0) - (pb[i] ?? 0)
334 if (d !== 0) return d < 0 ? -1 : 1
335 }
336 return 0
337}
338
339// The router runs older code than this plugin: one from 0.6.0 or earlier (no configDir) or a lower version. A newer router is
340// left alone, so two sessions on different plugin versions never take turns restarting it.
341export function routerIsStale(h: Health | null, pluginVersion: string): boolean {
342 if (!h) return false
343 if (!h.configDirs || h.configDirs.length === 0) return true
344 if (!h.version || !pluginVersion) return false
345 return compareVersions(h.version, pluginVersion) < 0
346}
347
348export function staleStatusText(lang: 'zh' | 'en' = 'zh'): string {
349 return lang === 'en'
350 ? 'The relay runs older code; run /provider restart when no reply is in progress'
351 : '轉發站還在跑舊版程式;沒有回覆進行中時執行 /provider restart 更新'
352}
353
354export function foreignStatusText(dir: string, lang: 'zh' | 'en' = 'zh'): string {
355 return lang === 'en'
356 ? `Port is held by another relay (config dir ${dir}): connected directly to Claude`
357 : `連接埠被另一個轉發站(設定目錄 ${dir})佔用:主線暫時直連 Claude`
358}
359
360// The router was started and did not answer: say where its own log explains why, once per outage
361export function startFailedText(configDir: string, lang: 'zh' | 'en' = 'zh'): string {
362 return lang === 'en'
363 ? `The relay did not start, so this session talks to Claude directly; the reason is in ${configDir}/model-relay.log (or run /provider doctor)`
364 : `轉發站沒有啟動,這個對話暫時直連 Claude;原因記在 ${configDir}/model-relay.log(或執行 /provider doctor)`
365}
366
367export function statusText(h: Health | null, direct: boolean, lang: 'zh' | 'en' = 'zh'): string {
368 if (!h) {
369 if (lang === 'en') return direct ? 'Relay offline: connected directly to Claude' : 'Relay offline, restarting…'
370 return direct ? '轉發站離線:主線暫時直連 Claude,model-relay 子代理無法使用' : '轉發站離線,正在重啟…'
371 }
372 const pinned = h.session !== 'claude' && has(h.providers, h.session) ? h.providers[h.session] : undefined
373 if (pinned) return lang === 'en' ? `Main: ${pinned} (pinned for this window)` : `主線:${pinned}(這個視窗固定用它)`
374 if (h.failoverSince) {
375 const to = h.failoverProvider ? h.providers[h.failoverProvider] ?? h.failoverProvider : ''
376 if (to) return lang === 'en' ? `Claude limit reached: routing to ${to}` : `Claude 額度用完:下一個請求起改用 ${to}`
377 return lang === 'en' ? 'Claude limit reached' : 'Claude 額度用完'
378 }
379 return lang === 'en' ? 'Main: Claude' : '主線:Claude'
380}
381
382// The relay state of this session for statusline tools (coralline's relay segment), written by the hook to
383// <config dir>/model-relay-status/<session id> as one line "kind<TAB>label":
384// claude <tab> claude this session goes to Claude through the relay
385// pinned <tab> <model> pinned to a provider (/provider, or a failover that pinned it)
386// failover <tab> <model> Claude's limit is reached; the next request goes to that model
387// limit <tab> claude Claude's limit is reached and the failover chain has nobody to hand over to
388// offline <tab> offline no router of ours answers for this window
389// direct <tab> claude no router of ours answers, so this window talks to Claude directly
390// foreign: the port answers for another config dir's router, which this window never counts as its own
391export function statusFields(h: Health | null, direct: boolean, foreign = false): [string, string] {
392 if (foreign || !h) return direct ? ['direct', 'claude'] : ['offline', 'offline']
393 const pinned = h.session !== 'claude' && has(h.providers, h.session) ? h.providers[h.session] : undefined
394 if (pinned) return ['pinned', pinned]
395 if (h.failoverSince) {
396 const to = h.failoverProvider ? h.providers[h.failoverProvider] ?? h.failoverProvider : ''
397 return to ? ['failover', to] : ['limit', 'claude']
398 }
399 return ['claude', 'claude']
400}
401
402// The status file's path and line; null for a session id that is not a plain file name. Tabs and line breaks in a
403// model id would split the line, so they become spaces.
404export function statusFile(configDir: string, sid: string, fields: [string, string]): { path: string; line: string } | null {
405 if (!/^[A-Za-z0-9._-]+$/.test(sid) || sid === '.' || sid === '..') return null
406 const clean = (s: string) => s.replace(/[\t\r\n]+/g, ' ')
407 return { path: `${configDir}/model-relay-status/${sid}`, line: `${clean(fields[0])}\t${clean(fields[1])}\n` }
408}
409
410// ---- avatar pane ----
411
412const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
413
414// The head square of an avatar entry, [centre x, centre y, side] in whole source pixels; null for anything else
415function parseHead(v: unknown): [number, number, number] | null {
416 if (!Array.isArray(v) || v.length !== 3 || !v.every(isNum)) return null
417 const [x, y, side] = v.map(Math.round) as [number, number, number]
418 return x >= 0 && y >= 0 && side > 0 ? [x, y, side] : null
419}
420
421// The config's "avatars": a persona name (claude or a provider, lower case like the router's) to an image path, or to
422// { image, head }. An entry without a usable image is dropped; a head that is not three numbers is dropped alone, so the
423// picture still shows.
424export function parseAvatars(raw: unknown): Record<string, AvatarEntry> {
425 if (!isObj(raw)) return {}
426 const entries: [string, AvatarEntry][] = []
427 for (const [key, v] of Object.entries(raw)) {
428 const name = key.trim().toLowerCase()
429 const image = typeof v === 'string' ? v : isObj(v) ? v.image : undefined
430 if (!name || typeof image !== 'string' || !image.trim()) continue
431 entries.push([name, { image: image.trim(), head: isObj(v) ? parseHead(v.head) : null }])
432 }
433 return Object.fromEntries(entries)
434}
435
436// The persona of this window for the avatar pane, by statusFields' rules: the pinned provider, else the one a Claude
437// limit handed the window to, else Claude (also with no router answering for it)
438export function avatarName(h: Health | null, foreign = false): string {
439 if (foreign || !h) return 'claude'
440 if (h.session !== 'claude' && has(h.providers, h.session)) return h.session
441 if (h.failoverSince && h.failoverProvider) return h.failoverProvider
442 return 'claude'
443}
444
445export function avatarLabel(name: string, providers: Record<string, string>): string {
446 if (name === 'claude') return 'Claude'
447 return has(providers, name) ? `${name} · ${providers[name]}` : name
448}
449
450// What the avatar pane is asked to show: the persona and the pane body the picture goes in (null until the pane has drawn
451// once). The pane shows the head square, or the whole picture for a persona with none.
452export type AvatarBody = { placement: 'dock' | 'inline'; columns: number; rows: number }
453export type AvatarAim = { name: string; label: string; body: AvatarBody | null }
454export type AvatarWant = Omit<AvatarView, 'cells' | 'error'>
455
456export const AVATAR_START: AvatarAim = { name: 'claude', label: 'Claude', body: null }
457export const AVATAR_EMPTY: AvatarView = { name: '', label: '', entry: null, mode: 'full', cols: 0, rows: 0, cells: null, error: null }
458
459// The picture's box in cells: as wide as the pane's body and as tall as it leaves after the label row under the picture.
460// The picture is fitted inside it at its true shape, a cell being
461// taken as 10 x 20 pixels. Inline above the prompt it stays short, to leave the transcript its room. 0 x 0 when nothing fits.
462export const AVATAR_MAX_COLS = 160
463export const AVATAR_MAX_ROWS = 100
464export const AVATAR_INLINE_ROWS = 12
465
466export const AVATAR_LABEL_ROWS = 1
467
468export function avatarSize(placement: 'dock' | 'inline', bodyColumns: number, bodyRows: number, labelRows: number): { cols: number; rows: number } {
469 const cols = Math.min(Math.floor(bodyColumns), AVATAR_MAX_COLS)
470 const rows = Math.min(Math.floor(bodyRows) - labelRows, placement === 'inline' ? AVATAR_INLINE_ROWS : AVATAR_MAX_ROWS)
471 return cols >= 1 && rows >= 1 ? { cols, rows } : { cols: 0, rows: 0 }
472}
473
474export function avatarWanted(aim: AvatarAim, entries: Record<string, AvatarEntry>): AvatarWant {
475 const entry = has(entries, aim.name) ? entries[aim.name]! : null
476 const { body } = aim
477 const size = body ? avatarSize(body.placement, body.columns, body.rows, AVATAR_LABEL_ROWS) : { cols: 0, rows: 0 }
478 return { name: aim.name, label: aim.label, entry, mode: entry?.head ? 'head' : 'full', ...size }
479}
480
481// A view is current when it was made from exactly what is wanted now, config entry included
482export const avatarMade = (v: AvatarView, w: AvatarWant): boolean =>
483 JSON.stringify([v.name, v.label, v.entry, v.mode, v.cols, v.rows]) === JSON.stringify([w.name, w.label, w.entry, w.mode, w.cols, w.rows])
484
485// Config paths are relative to the config directory unless they start from a root
486export function avatarSource(configDir: string, image: string): string {
487 const path = image.replace(/\\/g, '/')
488 return /^([A-Za-z]:)?\//.test(path) ? path : `${configDir}/${path}`
489}
490
491// One scratch file per window, so two windows converting at once never share an output
492export const avatarScratch = (configDir: string, sid: string): string => `${configDir}/model-relay-avatars/.cache/${sid.replace(/[^A-Za-z0-9_-]/g, '_') || 'window'}.rgba`
493
494const SAMPLES = 8 // samples across and down one cell: what its glyph and colors are judged on
495
496// How far the head crop may narrow from its square to take a tall box's shape (or flatten, for a wide one): further
497// would cut the hair beside the face
498export const AVATAR_MIN_SIDE = 0.75
499
500// The head crop in the box's shape, [width, height, centre x, centre y] in source pixels: narrowed (or flattened) toward
501// the box's shape no further than AVATAR_MIN_SIDE of the square, then grown along the other side to the box's shape, so
502// the face fills the box with no bands and is larger than the square alone would draw it
503export function avatarCrop(head: [number, number, number], cols: number, rows: number): [number, number, number, number] {
504 const [x, y, side] = head
505 const aspect = (cols * 10) / (rows * 20)
506 if (aspect < 1) {
507 const w = Math.round(side * Math.max(aspect, AVATAR_MIN_SIDE))
508 return [w, Math.round(w / aspect), x, y]
509 }
510 const h = Math.round(side * Math.max(1 / aspect, AVATAR_MIN_SIDE))
511 return [Math.round(h * aspect), h, x, y]
512}
513
514// The video filter that makes the box's samples: the head crop first when asked for (held inside the picture, its quoted
515// expressions keeping their commas from the filter chain), then the image fitted inside the box at its true shape with
516// transparent padding (a cell is taken to be 10 x 20 screen pixels), then scaled to the 8 x 8 samples each cell is judged
517// on, sharpened a little and given a little more color
518export function avatarFilter(mode: AvatarMode, head: [number, number, number] | null, cols: number, rows: number): string {
519 const [w, h] = [cols * 10, rows * 20]
520 let crop = ''
521 if (mode === 'head' && head) {
522 const [cw, ch, cx, cy] = avatarCrop(head, cols, rows)
523 crop = `crop=w='min(${cw},iw)':h='min(${ch},ih)':x='max(0,min(${cx}-ow/2,iw-ow))':y='max(0,min(${cy}-oh/2,ih-oh))',`
524 }
525 return `${crop}scale=${w}:${h}:force_original_aspect_ratio=decrease:flags=area,format=rgba,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2:color=0x00000000,scale=${cols * SAMPLES}:${rows * SAMPLES}:flags=area,unsharp=3:3:0.6,eq=saturation=1.12:contrast=1.04`
526}
527
528// The bytes of the raw RGBA frame the filter makes
529export const avatarBytes = (cols: number, rows: number): number => cols * SAMPLES * rows * SAMPLES * 4
530
531// ffmpeg writes one raw RGBA frame of cols*8 x rows*8 samples to `out`
532export function avatarArgv(input: string, out: string, mode: AvatarMode, head: [number, number, number] | null, cols: number, rows: number): string[] {
533 return ['ffmpeg', '-v', 'error', '-y', '-i', input, '-vf', avatarFilter(mode, head, cols, rows), '-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgba', out]
534}
535
536const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
537
538// Standard padded base64, written out because the engine's environment and plain Node do not share a built-in for it
539export function bytesToBase64(bytes: Uint8Array): string {
540 let out = ''
541 for (let i = 0; i < bytes.length; i += 3) {
542 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
543 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]! + (i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '=') + (i + 2 < bytes.length ? B64[n & 63]! : '=')
544 }
545 return out
546}
547
548export function base64ToBytes(text: string): Uint8Array {
549 const clean = text.replace(/[^A-Za-z0-9+/]/g, '')
550 const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
551 let acc = 0
552 let bits = 0
553 let at = 0
554 for (const ch of clean) {
555 acc = (acc << 6) | B64.indexOf(ch)
556 bits += 6
557 if (bits >= 8) {
558 bits -= 8
559 out[at++] = (acc >> bits) & 255
560 acc &= (1 << bits) - 1
561 }
562 }
563 return out
564}
565
566const CELL_DEFAULT = 0x01000000
567
568// A cell's 8 x 8 samples are summed as rows, columns and quarters (ROW + y, COL + x, QUARTER + q, left to right and top to
569// bottom): every glyph paints a union of these as its foreground, so what it paints and what it leaves come from a few sums
570// rather than from the 64 samples again
571const [ROW, COL, QUARTER] = [0, SAMPLES, 2 * SAMPLES]
572const GROUPS = 2 * SAMPLES + 4
573// Per group: samples, opaque samples (alpha 128 and up), the sums of r, g, b over the opaque ones, the sums of their squares
574const [N, SOLID, SUM, SQ, STAT] = [0, 1, 2, 5, 8]
575
576const range = (from: number, to: number): number[] => Array.from({ length: to - from }, (_, i) => from + i)
577
578// A glyph a cell may be drawn with, and the groups it paints as its foreground. `flip` is the glyph that paints the rest
579// instead, for a cell whose see-through part has to be the background: the terminal's default foreground is its text color,
580// so a glyph never carries it. A shape with no such glyph is left out for such a cell.
581type Shape = { cp: number; flip: number | null; parts: number[] }
582
583// Block shapes only: the full block, lower and left eighths, the quadrants. No triangles and no shades: both read as grain.
584const SHAPES: Shape[] = [
585 { cp: 0x2588, flip: 0x20, parts: range(ROW, ROW + SAMPLES) },
586 ...[1, 2, 3, 4, 5, 6, 7].map((k) => ({ cp: 0x2580 + k, flip: k === 4 ? 0x2580 : k === 7 ? 0x2594 : null, parts: range(ROW + SAMPLES - k, ROW + SAMPLES) })),
587 ...[1, 2, 3, 4, 5, 6, 7].map((k) => ({ cp: 0x2590 - k, flip: k === 4 ? 0x2590 : k === 7 ? 0x2595 : null, parts: range(COL, COL + k) })),
588 { cp: 0x2598, flip: 0x259f, parts: [QUARTER] },
589 { cp: 0x259d, flip: 0x2599, parts: [QUARTER + 1] },
590 { cp: 0x2596, flip: 0x259c, parts: [QUARTER + 2] },
591 { cp: 0x2597, flip: 0x259b, parts: [QUARTER + 3] },
592 { cp: 0x259a, flip: 0x259e, parts: [QUARTER, QUARTER + 3] },
593 { cp: 0x259e, flip: 0x259a, parts: [QUARTER + 1, QUARTER + 2] },
594]
595
596// A Raster paints 1024 distinct color pairs exactly and the rest as their nearest, so the palette is the largest of these
597// whose cells use at most that many (31 colors and the default make 32 x 32, which never goes over)
598const PALETTE_SIZES = [192, 160, 128, 96, 72, 56, 44, 31]
599const MAX_PAIRS = 1024
600// A box with more cells than this starts the search at 160 colors, to bound the work
601const BIG_BOX_CELLS = 2200
602const WEIGHTS = [0.3, 0.59, 0.11]
603// A sample that is painted over, or left without a color, counts as this much error (half of 255 squared)
604const HALF_ERR = 65025 * 0.5
605// A shape has to beat the best so far by this much: a flat cell fits every shape equally, up to rounding, and stays the
606// full block (one color pair) instead of one of the others at random
607const TIE_ERR = 1e-3
608
609// Median cut over the opaque samples (every fourth pixel): the palette after each of `sizes` splits, as flat r, g, b
610// lists. The splits run once, since a smaller palette is the same cut stopped earlier.
611function medianCut(rgba: Uint8Array, sizes: number[]): Map<number, number[]> {
612 const rgb: number[] = []
613 for (let i = 0; i < rgba.length; i += 16) if ((rgba[i + 3] ?? 0) >= 128) rgb.push(rgba[i]!, rgba[i + 1]!, rgba[i + 2]!)
614 type Box = { items: number[]; span: number; channel: number }
615 const measure = (items: number[]): Box => {
616 let span = -1
617 let channel = 0
618 if (items.length >= 2) {
619 for (let c = 0; c < 3; c++) {
620 let [lo, hi] = [255, 0]
621 for (const i of items) {
622 const v = rgb[i * 3 + c]!
623 if (v < lo) lo = v
624 if (v > hi) hi = v
625 }
626 if (hi - lo > span) [span, channel] = [hi - lo, c]
627 }
628 }
629 return { items, span, channel }
630 }
631 const mean = (boxes: Box[]): number[] =>
632 boxes.filter((b) => b.items.length).flatMap((b) => [0, 1, 2].map((c) => b.items.reduce((s, i) => s + rgb[i * 3 + c]!, 0) / b.items.length))
633 const out = new Map<number, number[]>()
634 const boxes = [measure(Array.from({ length: rgb.length / 3 }, (_, i) => i))]
635 for (const n of [...sizes].sort((a, b) => a - b)) {
636 while (boxes.length < n) {
637 let pick = -1
638 boxes.forEach((b, i) => {
639 if (b.span >= 0 && (pick < 0 || b.span > boxes[pick]!.span)) pick = i
640 })
641 if (pick < 0) break
642 const { items, channel } = boxes[pick]!
643 const sorted = [...items].sort((p, q) => rgb[p * 3 + channel]! - rgb[q * 3 + channel]!)
644 boxes.splice(pick, 1, measure(sorted.slice(0, sorted.length >> 1)), measure(sorted.slice(sorted.length >> 1)))
645 }
646 out.set(n, mean(boxes))
647 }
648 return out
649}
650
651type Snap = (r: number, g: number, b: number) => number[]
652
653// The cells as u32 triplets [code point, foreground, background], and how many distinct color pairs they use
654type Painted = { words: Uint32Array; pairs: number }
655
656// What one side of a glyph costs, from the sums of its samples: the error it leaves, and the color to paint it in
657// `colors[at]`. A side mostly see-through is the terminal's default color; the rest take the palette's color nearest their
658// mean. Without a `snap` the color is the mean itself, and the error the least any color leaves: a floor under the real one.
659function judge(v: Float64Array, snap: Snap | null, colors: number[], at: number): number {
660 const total = v[N]!
661 const solid = v[SOLID]!
662 if (solid * 2 < total || total === 0) {
663 colors[at] = CELL_DEFAULT
664 return solid * HALF_ERR
665 }
666 let r = v[SUM]! / solid
667 let g = v[SUM + 1]! / solid
668 let b = v[SUM + 2]! / solid
669 if (snap) {
670 const near = snap(r, g, b)
671 r = near[0]!
672 g = near[1]!
673 b = near[2]!
674 }
675 colors[at] = (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(b)
676 let err = (total - solid) * HALF_ERR
677 err += WEIGHTS[0]! * (v[SQ]! - 2 * r * v[SUM]! + solid * r * r)
678 err += WEIGHTS[1]! * (v[SQ + 1]! - 2 * g * v[SUM + 1]! + solid * g * g)
679 err += WEIGHTS[2]! * (v[SQ + 2]! - 2 * b * v[SUM + 2]! + solid * b * b)
680 return err
681}
682
683const [whole, painted, rest] = [new Float64Array(STAT), new Float64Array(STAT), new Float64Array(STAT)]
684const picked = [0, 0]
685
686// The glyph and the two colors with the least error for one cell, from its groups' sums: [code point, foreground, background]
687function bestCell(groups: Float64Array, snap: Snap): [number, number, number] {
688 whole.fill(0)
689 for (let g = ROW; g < ROW + SAMPLES; g++) for (let j = 0; j < STAT; j++) whole[j]! += groups[g * STAT + j]!
690 let best = Infinity
691 let cell: [number, number, number] = [0x20, CELL_DEFAULT, CELL_DEFAULT]
692 for (const { cp, flip, parts } of SHAPES) {
693 painted.fill(0)
694 for (const g of parts) for (let j = 0; j < STAT; j++) painted[j]! += groups[g * STAT + j]!
695 for (let j = 0; j < STAT; j++) rest[j] = whole[j]! - painted[j]!
696 // A shape that cannot beat the best even with the exact mean for colors is not worth finding its palette colors
697 if (judge(rest, null, picked, 0) + judge(painted, null, picked, 1) >= best - TIE_ERR) continue
698 const err = judge(rest, snap, picked, 0) + judge(painted, snap, picked, 1)
699 if (err >= best - TIE_ERR) continue
700 const bg = picked[0]!
701 const fg = picked[1]!
702 if (fg !== CELL_DEFAULT) [best, cell] = [err, [cp, fg, bg]]
703 else if (bg === CELL_DEFAULT) [best, cell] = [err, [0x20, CELL_DEFAULT, CELL_DEFAULT]]
704 else if (flip !== null) [best, cell] = [err, [flip, bg, CELL_DEFAULT]]
705 }
706 return cell
707}
708
709function paintCells(rgba: Uint8Array, cols: number, rows: number, palette: number[]): Painted {
710 const snap: Snap = (r, g, b) => {
711 let best = Infinity
712 let near = -1
713 for (let i = 0; i < palette.length; i += 3) {
714 let d = palette[i]! - r
715 d = WEIGHTS[0]! * (d * d)
716 if (d >= best) continue
717 let e = palette[i + 1]! - g
718 d += WEIGHTS[1]! * (e * e)
719 if (d >= best) continue
720 e = palette[i + 2]! - b
721 d += WEIGHTS[2]! * (e * e)
722 if (d < best) {
723 best = d
724 near = i
725 }
726 }
727 return near < 0 ? [r, g, b] : [palette[near]!, palette[near + 1]!, palette[near + 2]!]
728 }
729 const words = new Uint32Array(cols * rows * 3)
730 const pairs = new Set<number>()
731 const groups = new Float64Array(GROUPS * STAT)
732 // The sample at byte `i` of the frame, opaque or not, counted into a group
733 const add = (group: number, i: number, solid: number): void => {
734 const at = group * STAT
735 const r = solid * rgba[i]!
736 const g = solid * rgba[i + 1]!
737 const b = solid * rgba[i + 2]!
738 groups[at + N]!++
739 groups[at + SOLID]! += solid
740 groups[at + SUM]! += r
741 groups[at + SUM + 1]! += g
742 groups[at + SUM + 2]! += b
743 groups[at + SQ]! += r * r
744 groups[at + SQ + 1]! += g * g
745 groups[at + SQ + 2]! += b * b
746 }
747 const width = cols * SAMPLES
748 for (let cy = 0; cy < rows; cy++) {
749 for (let cx = 0; cx < cols; cx++) {
750 groups.fill(0)
751 for (let y = 0; y < SAMPLES; y++) {
752 for (let x = 0; x < SAMPLES; x++) {
753 const i = ((cy * SAMPLES + y) * width + cx * SAMPLES + x) * 4
754 const solid = (rgba[i + 3] ?? 0) >= 128 ? 1 : 0
755 const quarter = (y >= SAMPLES / 2 ? 2 : 0) + (x >= SAMPLES / 2 ? 1 : 0)
756 add(ROW + y, i, solid)
757 add(COL + x, i, solid)
758 add(QUARTER + quarter, i, solid)
759 }
760 }
761 const [cp, fg, bg] = bestCell(groups, snap)
762 words.set([cp, fg, bg], (cy * cols + cx) * 3)
763 pairs.add(fg * 0x2000000 + bg)
764 }
765 }
766 return { words, pairs: pairs.size }
767}
768
769// RGBA samples (cols*8 wide, rows*8 tall) as cells: per cell the block glyph and two colors that match its 8 x 8 samples
770// best, every color snapped to a median-cut palette of the largest size whose cells use at most 1024 distinct color pairs
771export function glyphCells(rgba: Uint8Array, cols: number, rows: number): Painted {
772 const sizes = cols * rows > BIG_BOX_CELLS ? PALETTE_SIZES.filter((n) => n <= 160) : PALETTE_SIZES
773 const palettes = medianCut(rgba, sizes)
774 let made: Painted = { words: new Uint32Array(0), pairs: 0 }
775 let tried = -1
776 for (const n of sizes) {
777 const palette = palettes.get(n)!
778 if (palette.length === tried) continue // the cut ran out of colors: this is the palette just tried
779 tried = palette.length
780 made = paintCells(rgba, cols, rows, palette)
781 if (made.pairs <= MAX_PAIRS) break
782 }
783 return made
784}
785
786// The cells of glyphCells as a Raster takes them: little-endian u32 triplets [code point, foreground, background], base64
787export function rasterCells(rgba: Uint8Array, cols: number, rows: number): string {
788 const { words } = glyphCells(rgba, cols, rows)
789 const view = new DataView(new ArrayBuffer(words.length * 4))
790 words.forEach((word, i) => view.setUint32(i * 4, word, true))
791 return bytesToBase64(new Uint8Array(view.buffer))
792}
793
794export function fmtLatency(ms: number): string {
795 return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`
796}
797
798// The last test of a provider, in a few words, kept apart from the last real request
799export function pingText(p: PingStat | undefined, lang: 'zh' | 'en'): { text: string; color: string } | null {
800 if (!p) return null
801 const en = lang === 'en'
802 if (p.verdict === 'ok') return { text: `${en ? 'test ok' : '測試正常'} ${fmtLatency(p.firstByteMs)}`, color: 'green' }
803 const words: Record<string, [string, string]> = {
804 key: ['key refused', '金鑰被拒'],
805 model: ['unknown model', '模型名稱不對'],
806 quota: ['quota or rate limit', '額度用完或被限流'],
807 'not-running': ['local service not running', '本機服務沒開'],
808 unreachable: ['unreachable', '連不到'],
809 timeout: ['timed out', '逾時'],
810 }
811 const w = has(words, p.verdict) ? words[p.verdict]! : [`HTTP ${p.status}`, `HTTP ${p.status}`]
812 return { text: `${en ? 'test' : '測試'}: ${en ? w[0] : w[1]}`, color: 'red' }
813}
814
815export function statText(st: ProviderStat | undefined, lang: 'zh' | 'en'): { text: string; color: string } | null {
816 if (!st || !st.at) return null
817 const head = lang === 'en' ? 'last' : '上次'
818 if (st.status >= 200 && st.status < 300) return { text: `${head} ${st.status} · ${fmtLatency(st.ms)}`, color: 'green' }
819 if (st.status === 0) return { text: `${head} ${lang === 'en' ? 'no connection' : '未連線'}`, color: 'red' }
820 return { text: `${head} HTTP ${st.status}`, color: 'red' }
821}
822
823// What one switch came to, in one line: the status line and the panel already show the rest
824export function switchedText(target: string, model: string, lang: 'zh' | 'en' = 'zh'): string {
825 if (target === 'claude') {
826 return lang === 'en'
827 ? '✓ This window is back on Claude (the next turn rebuilds the prompt cache)'
828 : '✓ 這個視窗切回 Claude(下一輪會重建一次快取)'
829 }
830 return lang === 'en' ? `✓ This window now uses ${target} (${model})` : `✓ 這個視窗改用 ${target}(${model})`
831}
832
833// One row per route, each written as the command that picks it
834function routeRows(h: Health, lang: 'zh' | 'en', withClaude: boolean): string[] {
835 const en = lang === 'en'
836 const active = h.session || 'claude'
837 const rows: string[] = []
838 if (withClaude) {
839 const desc = active === 'claude'
840 ? (en ? 'Anthropic Claude ← in use' : 'Anthropic Claude ← 使用中')
841 : (en ? 'Anthropic Claude (switching back rebuilds the prompt cache)' : 'Anthropic Claude(切回時長對話會重建一次快取)')
842 rows.push(` ${active === 'claude' ? '●' : '○'} /provider ${'claude'.padEnd(8)} ${desc}`)
843 }
844 for (const [k, m] of Object.entries(h.providers)) {
845 const notes = [statText(h.stats?.[k], lang), pingText(h.ping?.[k], lang)].filter((x) => x !== null).map((x) => x.text)
846 const noteText = notes.length ? (en ? ` (${notes.join(' · ')})` : `(${notes.join(' · ')})`) : ''
847 const mine = active === k ? (en ? ' ← in use' : ' ← 使用中') : ''
848 rows.push(` ${active === k ? '●' : '○'} /provider ${k.padEnd(8)} ${m}${noteText}${mine}`)
849 }
850 if (Object.keys(h.providers).length === 0) {
851 rows.push(en
852 ? ' (no provider is usable yet: every request goes to Claude; /provider setup adds one)'
853 : ' (還沒有可用的供應商:所有請求都走 Claude;/provider setup 可以新增)')
854 }
855 return rows
856}
857
858// The switches every window shares, and where each Claude alias goes, on one line
859function sharedLine(h: Health, lang: 'zh' | 'en'): string {
860 const en = lang === 'en'
861 const sw = h.switches
862 const onOff = (on: boolean) => (on ? `● ${en ? 'on' : '開啟'}` : `○ ${en ? 'off' : '關閉'}`)
863 const foOn = sw ? sw.failover : true
864 const chain = h.failoverChain.length ? h.failoverChain.join(' → ') : en ? 'nobody to hand over to' : '沒有接手者'
865 const parts = [
866 `${en ? '429 failover' : '429 備援'} ${onOff(foOn)}${foOn ? (en ? ` (${chain})` : `(${chain})`) : ''}`,
867 `${en ? 'Retry' : '失敗重試'} ${onOff(!sw || sw.retry)}`,
868 `${en ? 'Remote Control' : '手機遙控'} ${onOff(!!sw?.proxy)}`,
869 ]
870 const aliases = Object.entries(h.aliases).filter(([, p]) => has(h.providers, p)).map(([slot, p]) => `${slot} → ${p}`)
871 if (aliases.length) parts.push(`${en ? 'Aliases' : '別名'} ${aliases.join(en ? ', ' : '、')}`)
872 return `${en ? 'Shared by every window: ' : '所有視窗共用:'}${parts.join(en ? ' · ' : '・')}`
873}
874
875// /provider status: where this window goes, what every window shares, and what to fix; the commands are in /provider help
876export function providerReport(h: Health | null, now: number, lang: 'zh' | 'en' = 'zh'): string {
877 const en = lang === 'en'
878 if (!h) {
879 return en
880 ? 'Relay is not responding; auto-restarting every 10s.'
881 : '轉發站沒有回應;每 10 秒會自動重啟,稍後再試。'
882 }
883 let head = statusText(h, false, lang)
884 if (h.failoverSince) {
885 const mins = Math.max(0, Math.round((h.nextClaudeTry - now) / 60000))
886 head += en
887 ? ` (retrying Claude in ~${mins}m; only new sessions switch back)`
888 : `(約 ${mins} 分鐘後再試 Claude,恢復後只有新對話回到 Claude)`
889 }
890 return [
891 head,
892 sharedLine(h, lang),
893 ...healthHints(h, lang).map((x) => `⚠ ${x}`),
894 '',
895 en ? 'Switch this window:' : '切換這個視窗:',
896 ...routeRows(h, lang, true),
897 '',
898 en ? 'All commands: /provider help' : '所有指令:/provider help',
899 ].join('\n')
900}
901
902// /provider ping: each provider with what its test came to
903export function pingReport(h: Health | null, lang: 'zh' | 'en' = 'zh'): string {
904 if (!h) return providerReport(null, 0, lang)
905 return [lang === 'en' ? 'Test results:' : '測試結果:', ...routeRows(h, lang, false)].join('\n')
906}
907
908// /provider help: every command, grouped by what it reaches
909export function helpText(providers: string[], lang: 'zh' | 'en' = 'zh'): string {
910 const en = lang === 'en'
911 const groups: [string, [string, string][]][] = [
912 [en ? 'This window:' : '這個視窗:', [
913 ['/provider', en ? "Open the panel to switch this window's model" : '開啟面板,切換這個視窗的模型'],
914 ['/provider <name> | claude', en ? 'Switch this window directly (e.g. /provider claude)' : '直接切換這個視窗(例:/provider claude)'],
915 ['/provider status', en ? 'The same status as text' : '文字版狀態'],
916 ]],
917 [en ? 'Shared by every window:' : '所有視窗共用:', [
918 ['/provider failover <on|off|a,b>', en ? 'Turn 429 failover on or off, or set who takes over' : '開關 429 備援或設定接手順序'],
919 ['/provider retry <on|off>', en ? 'Turn automatic provider retries on or off' : '開關供應商失敗自動重試'],
920 ['/provider rc <on|off>', en ? 'Turn Remote Control proxy mode on or off (new sessions)' : '開關手機遙控代理模式(新對話生效)'],
921 ['/provider alias [haiku <name|claude>]', en ? 'View or point a Claude model alias' : '查看或設定別名分流'],
922 ['/provider probe', en ? 'Reset the 429 cooldown so the next new session tries Claude' : '重置 429 冷卻,下一個新對話先試 Claude'],
923 ]],
924 [en ? 'Setup and checks:' : '設定與檢查:', [
925 ['/provider ping', en ? 'Test every provider: does it answer, and how soon' : '測試每個供應商:能不能回應、多快開始回應'],
926 ['/provider setup [goal]', en ? 'Let Claude walk you through setup (e.g. /provider setup add deepseek)' : '讓 Claude 帶你設定(例:/provider setup 加上 deepseek)'],
927 ['/provider doctor [--ping] [--fix]', en ? 'Check the config, Claude Code settings and the relay; changes nothing unless --fix (wires settings.json)' : '檢查設定檔、Claude Code 設定與轉發站;不加 --fix 不會改任何東西(--fix 會設定 settings.json)'],
928 ['/provider restart', en ? 'Restart the relay so it runs this plugin version' : '重啟轉發站,讓它跑這個外掛版本的程式'],
929 ['/provider init', en ? 'Create an empty config' : '建立空的設定檔'],
930 ]],
931 ]
932 const width = Math.max(...groups.flatMap(([, rows]) => rows.map(([cmd]) => cmd.length)))
933 const lines = groups.flatMap(([title, rows]) => [title, ...rows.map(([cmd, desc]) => ` ${cmd.padEnd(width)} ${desc}`)])
934 if (providers.length) {
935 const names = providers.map((k) => `model-relay:${k}`).join(en ? ', ' : '、')
936 lines.push(en
937 ? `Subagents: subagent_type "model-relay:<name>" runs a full Agent on that provider (${names})`
938 : `子代理:subagent_type "model-relay:<名稱>" 派出等同預設 Agent 的子代理並走該供應商(${names})`)
939 }
940 return lines.join('\n')
941}
942
943export interface ProviderAgentSpec extends AgentOptions {
944 name: string
945 description: string
946 prompt: string
947 model: string
948}
949
950const DEFAULT_AGENT_PROMPT =
951 "You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, use the available tools to complete the task fully and report your findings and changes clearly and concisely."
952
953// Builds one `$.agent.register` spec per provider the router serves. `tools` is omitted so the subagent inherits every
954// tool the parent has (identical to the built-in general-purpose Agent), while `model` is set to the provider's model ID.
955// `options` holds each provider's "agent" object from the config: what it sets replaces these defaults as written.
956export function providerAgentSpecs(providers: Record<string, string>, options: Record<string, AgentOptions> = {}): ProviderAgentSpec[] {
957 return Object.entries(providers).map(([name, model]) => ({
958 description: `General-purpose agent routed to ${name} (${model}) via model-relay. Same full toolset as the default Agent tool.`,
959 prompt: DEFAULT_AGENT_PROMPT,
960 ...(has(options, name) ? options[name] : {}),
961 name,
962 model,
963 }))
964}
965
966// The provider a `model-relay:<name>` subagent type names, with its model; null for any other type. When the parent calls
967// `Agent({ subagent_type: "model-relay:<name>", model: "sonnet" })`, the explicit `model` would otherwise override the
968// agent definition's, so `on('agent.spawn')` locks it back to the provider's model.
969export function relayAgent(subagentType: string, providers: Record<string, string>): { name: string; model: string } | null {
970 const m = /^model-relay:([a-z0-9_-]+)$/i.exec((subagentType || '').trim())
971 if (!m) return null
972 const name = m[1]!.toLowerCase()
973 return has(providers, name) ? { name, model: providers[name]! } : null
974}
975
976// A model id as it travels: without a "[1m]"-style suffix, lower case (the router's seenModels uses the same key)
977export const wireKey = (model: string) => model.replace(/\[[^\]]*\]$/, '').toLowerCase()
978
979// Claude Code can run a model-relay:<name> subagent on another model (one missing from availableModels is swapped with
980// only a debug-log line); the spawn result names the model it really got
981export const spawnedElsewhere = (wanted: string, got: string | undefined) => Boolean(got) && wireKey(got!) !== wireKey(wanted)
982
983// After the subagent's turn: did a request for its model reach the relay since it started? null when the router cannot
984// say (down, or an older router without seenModels), so nobody is warned on a guess. Two seconds of slack for the two clocks' reads.
985export function reachedRelay(h: Health | null, wanted: string, since: number): boolean | null {
986 if (!h || !h.seenModels) return null
987 const at = h.seenModels[wireKey(wanted)]
988 return typeof at === 'number' && at >= since - 2000
989}
990
991export function elsewhereText(name: string, wanted: string, got: string, lang: 'zh' | 'en'): string {
992 return lang === 'en'
993 ? `The model-relay:${name} subagent runs on ${got}, not ${wanted}. Usually availableModels in a settings file does not list ${wanted}; run /provider doctor`
994 : `model-relay:${name} 子代理實際跑在 ${got},不是 ${wanted}。常見原因是設定檔的 availableModels 沒列出 ${wanted};執行 /provider doctor 檢查`
995}
996
997export function missedRelayText(name: string, wanted: string, lang: 'zh' | 'en'): string {
998 return lang === 'en'
999 ? `The model-relay:${name} subagent finished, but no request for ${wanted} reached the relay, so it did not run on ${name}. Run /provider doctor (a Bedrock/Vertex setting or another ANTHROPIC_BASE_URL can cause this)`
1000 : `model-relay:${name} 子代理跑完了,但轉發站沒收到 ${wanted} 的請求,所以它沒有走 ${name}。執行 /provider doctor 檢查(Bedrock/Vertex 設定或別的 ANTHROPIC_BASE_URL 都可能造成)`
1001}
1002hooks/avatar.tsx 36 lines1// The avatar pane of model-relay: the head of the model this window talks to, drawn as a Raster, with its name under it.
2// Pure view function, like panel.tsx: resolved elements and the view.
3import type { EngineInterface } from 'claude-code'
4import type { AvatarView } from '../types'
5import { fit } from './logic'
6
7type Elements = ReturnType<EngineInterface['ui']['resolve']>
8
9// Under the picture: the label, one row (AVATAR_LABEL_ROWS in logic.ts is what the picture's box leaves for it)
10export function avatarTree(ui: Elements, v: AvatarView, columns: number, lang: 'zh' | 'en') {
11 const { Box, Text } = ui
12 // Only the terminal draws a Raster; elsewhere the pane is the label alone
13 const Raster = 'Raster' in ui ? ui.Raster : null
14 const en = lang === 'en'
15 if (!v.name) return <Box />
16
17 const note =
18 v.error === 'unset'
19 ? (en
20 ? `No picture for ${v.name} yet: add avatars.${v.name} to model-relay.json (see the README)`
21 : `${v.name} 還沒有頭像:在 model-relay.json 加上 avatars.${v.name}(見 README)`)
22 : v.error === 'failed'
23 ? (en
24 ? 'No picture: is ffmpeg on PATH, and does the image file exist?'
25 : '做不出頭像:ffmpeg 在 PATH 上嗎?圖片檔存在嗎?')
26 : null
27
28 return (
29 <Box flexDirection="column">
30 {Raster && v.cells && v.cols > 0 ? <Raster key="avatar" columns={v.cols} rows={v.rows} cells={v.cells} /> : null}
31 {note ? <Text dimColor>{note}</Text> : null}
32 <Text bold>{fit(v.label, columns)}</Text>
33 </Box>
34 )
35}
36hooks/panel.tsx 218 lines1// Interactive AbovePrompt panel for model-relay (/provider).
2// Pure view function: receives resolved UI elements, snapshot data, and action callbacks.
3// It is for one thing, picking this window's model; what every window shares stays folded behind Settings [s].
4import type { EngineInterface } from 'claude-code'
5import type { PanelData, PanelUi } from '../types'
6import { fit, pingText, statText } from './logic'
7
8type Elements = ReturnType<EngineInterface['ui']['resolve']>
9
10export type PanelView = PanelData & PanelUi & { columns: number }
11
12export type PanelActions = {
13 switchTo: (target: string) => void
14 askClaude: (ask: boolean) => void
15 ping: () => void
16 probe: () => void
17 settings: (show: boolean) => void
18 toggle: (which: 'failover' | 'retry' | 'proxy', on: boolean) => void
19 askProxy: (ask: boolean) => void
20 close: () => void
21}
22
23const ACCENT = 'cyan'
24const FRAME_CELLS = 4
25
26export function panelTree(ui: Elements, v: PanelView, act: PanelActions) {
27 const { Box, Text, Button } = ui
28 const inner = Math.max(32, v.columns - FRAME_CELLS)
29 const en = v.lang === 'en'
30
31 const fo = v.failoverProvider
32 const foText = fo ? `${fo} (${v.providers[fo] ?? fo})` : (en ? 'nobody to hand over to' : '沒有接手者')
33 const mins = v.failoverSince ? Math.max(0, Math.round((v.nextClaudeTry - v.now) / 60000)) : 0
34 const aliasPairs = Object.entries(v.aliases ?? {})
35 .filter(([, p]) => v.providers[p])
36 .map(([slot, p]) => `${slot} → ${p} (${v.providers[p]})`)
37
38 // Only what changes where this window's requests go gets a line of its own
39 const alert = !v.online
40 ? (en
41 ? (v.direct ? 'Relay offline: this window talks to Claude directly' : 'Relay offline: restarting every 10s…')
42 : (v.direct ? '轉發站離線:這個視窗暫時直連 Claude' : '轉發站離線:每 10 秒自動重啟中…'))
43 : v.failoverSince
44 ? (en
45 ? `Claude limit reached: requests go to ${foText}; Claude again in ~${mins}m`
46 : `Claude 額度用完:請求改走 ${foText},約 ${mins} 分鐘後再試 Claude`)
47 : null
48
49 const inUse = <Text dimColor>{en ? 'In use' : '使用中'}</Text>
50 const mark = (active: boolean) => <Text color={active ? 'green' : 'gray'} bold>{active ? '● ' : '○ '}</Text>
51
52 const isClaudeActive = v.session === 'claude'
53 const claudeRow = (
54 <Box key="route:claude">
55 <Box flexGrow={1}>
56 <Text>
57 {mark(isClaudeActive)}
58 <Text bold={isClaudeActive}>{'claude'.padEnd(8)}</Text>{' '}
59 <Text dimColor>Anthropic Claude</Text>
60 </Text>
61 </Box>
62 {isClaudeActive
63 ? inUse
64 : v.confirmingClaude
65 ? [
66 <Button
67 key="confirm-claude"
68 label={en ? 'Confirm (rebuilds cache)' : '確定切回(重建快取)'}
69 variant="primary"
70 onPress={() => act.switchTo('claude')}
71 />,
72 <Button key="cancel-claude" label={en ? 'Cancel' : '取消'} onPress={() => act.askClaude(false)} />,
73 ]
74 : <Button key="btn:claude" label={en ? 'Use [1]' : '切換 [1]'} hotkey="1" onPress={() => act.askClaude(true)} />}
75 </Box>
76 )
77
78 const providerRows = Object.entries(v.providers).map(([name, model], idx) => {
79 const active = v.session === name
80 // the last real request and the last test are shown apart: a long reply is not a slow provider
81 const notes = [statText(v.stats[name], v.lang), pingText(v.ping?.[name], v.lang)].filter((x) => x !== null)
82 const hk = String(idx + 2)
83 return (
84 <Box key={`route:${name}`}>
85 <Box flexGrow={1}>
86 <Text>
87 {mark(active)}
88 <Text bold={active}>{name.padEnd(8)}</Text>{' '}
89 <Text color={active ? ACCENT : undefined}>{model}</Text>
90 {notes.map((n, i) => <Text key={`note:${i}`} color={n.color}>{` · ${n.text}`}</Text>)}
91 </Text>
92 </Box>
93 {active
94 ? inUse
95 : <Button key={`btn:${name}`} label={en ? `Use [${hk}]` : `切換 [${hk}]`} hotkey={hk} variant="primary" onPress={() => act.switchTo(name)} />}
96 </Box>
97 )
98 })
99
100 // One row per switch: what it is, whether it is on, and what flipping it does. failover and retry apply on the
101 // router's next request; the proxy rewrites settings.json, so it asks first and only reaches new sessions.
102 const sw = v.switches
103 const chainText = v.failoverChain.length ? v.failoverChain.join(' → ') : (en ? 'nobody to hand over to' : '沒有接手者')
104 const switchRow = (
105 id: 'failover' | 'retry' | 'proxy',
106 name: string,
107 on: boolean,
108 detail: string,
109 button: ReturnType<typeof Button> | ReturnType<typeof Button>[],
110 ) => (
111 <Box key={`switch:${id}`}>
112 <Box flexGrow={1}>
113 <Text>
114 {mark(on)}
115 <Text bold>{name}</Text>{' '}
116 <Text dimColor>{fit(`· ${detail}`, Math.max(20, inner - 30))}</Text>
117 </Text>
118 </Box>
119 {button}
120 </Box>
121 )
122 const flipLabel = (on: boolean, key: string) => (on ? (en ? `Turn off [${key}]` : `關閉 [${key}]`) : (en ? `Turn on [${key}]` : `開啟 [${key}]`))
123 const hasSettings = !!sw || aliasPairs.length > 0
124 const settings = v.showSettings && hasSettings
125 ? (
126 <Box flexDirection="column" marginTop={1}>
127 <Text bold>{en ? 'Shared by every window' : '所有視窗共用'}</Text>
128 {sw
129 ? [
130 switchRow(
131 'failover',
132 en ? '429 failover' : '429 備援',
133 sw.failover,
134 sw.failover ? (en ? `on (${chainText})` : `開啟(${chainText})`) : (en ? 'off (429 returns error)' : '關閉(額度用完直接回報)'),
135 <Button key="btn:failover" label={flipLabel(sw.failover, 'f')} hotkey="f" onPress={() => act.toggle('failover', !sw.failover)} />,
136 ),
137 switchRow(
138 'retry',
139 en ? 'Retry' : '失敗重試',
140 sw.retry,
141 sw.retry ? (en ? 'on (backoff on 429/5xx)' : '開啟(429/5xx 自動退避重試)') : (en ? 'off (fail immediately)' : '關閉(出錯立即回報)'),
142 <Button key="btn:retry" label={flipLabel(sw.retry, 't')} hotkey="t" onPress={() => act.toggle('retry', !sw.retry)} />,
143 ),
144 switchRow(
145 'proxy',
146 en ? 'Remote Control' : '手機遙控',
147 sw.proxy,
148 sw.proxy ? (en ? 'on (HTTPS proxy · new sessions)' : '開啟(HTTPS 代理・新對話生效)') : (en ? 'off (base-URL mode)' : '關閉(一般 API 位址模式)'),
149 v.confirmingProxy
150 ? [
151 <Button
152 key="confirm-proxy"
153 label={sw.proxy
154 ? (en ? 'Confirm (running remote sessions drop)' : '確定關閉(執行中的遙控對話會斷線)')
155 : (en ? 'Confirm (edits settings.json)' : '確定開啟(會改 settings.json)')}
156 variant="primary"
157 onPress={() => act.toggle('proxy', !sw.proxy)}
158 />,
159 <Button key="cancel-proxy" label={en ? 'Cancel' : '取消'} onPress={() => act.askProxy(false)} />,
160 ]
161 : <Button key="btn:proxy" label={flipLabel(sw.proxy, 'x')} hotkey="x" onPress={() => act.askProxy(true)} />,
162 ),
163 ]
164 : null}
165 {aliasPairs.length > 0
166 ? <Text dimColor>{fit(`${en ? 'Aliases: ' : '別名分流:'}${aliasPairs.join(en ? ', ' : '、')}`, inner)}</Text>
167 : null}
168 </Box>
169 )
170 : null
171
172 return (
173 <Box flexDirection="column" borderStyle="round" borderColor={ACCENT} paddingX={1}>
174 <Box>
175 <Box flexGrow={1}>
176 <Text color={ACCENT} bold>model-relay</Text>
177 <Text dimColor>{en ? " · this window's model" : '・這個視窗用哪個模型'}</Text>
178 </Box>
179 <Button key="ping" label={v.pinging ? (en ? 'Testing…' : '測速中…') : (en ? 'Ping [p]' : '測速 [p]')} hotkey="p" onPress={act.ping} />
180 {v.failoverSince > 0
181 ? <Button key="probe" label={en ? 'Retry Claude [r]' : '重試 Claude [r]'} hotkey="r" onPress={act.probe} />
182 : null}
183 {hasSettings
184 ? (
185 <Button
186 key="settings"
187 label={v.showSettings ? (en ? 'Hide settings [s]' : '收起設定 [s]') : (en ? 'Settings [s]' : '設定 [s]')}
188 hotkey="s"
189 onPress={() => act.settings(!v.showSettings)}
190 />
191 )
192 : null}
193 <Button key="close" label={en ? 'Close' : '關閉'} role="dismiss" dimColor onPress={act.close} />
194 </Box>
195
196 {alert ? <Text color="yellow">{fit(alert, inner)}</Text> : null}
197 {v.note ? <Text color={v.note.ok ? 'green' : 'yellow'}>{fit(v.note.text, inner)}</Text> : null}
198 {(v.hints ?? []).slice(0, 2).map((hint, i) => (
199 <Text key={`hint:${i}`} color="yellow">{fit(`⚠ ${hint}`, inner)}</Text>
200 ))}
201 {providerRows.length === 0 && v.online
202 ? <Text dimColor>{fit(en ? 'No provider is usable yet: every request goes to Claude; /provider setup adds one' : '還沒有可用的供應商:所有請求都走 Claude;/provider setup 可以新增', inner)}</Text>
203 : null}
204
205 <Box flexDirection="column" marginTop={1}>
206 {claudeRow}
207 {providerRows}
208 </Box>
209
210 {settings}
211
212 <Box marginTop={1}>
213 <Text dimColor>{fit(en ? 'Keyboard: ctrl+x tab, then the key shown on a button' : '鍵盤:先按 ctrl+x tab,再按按鈕上標的鍵', inner)}</Text>
214 </Box>
215 </Box>
216 )
217}
218types/index.d.ts 82 lines1export type ProviderStat = {
2 status: number
3 ms: number
4 at: number
5}
6
7// The last test of a provider (/provider ping): ok | key | model | quota | not-running | unreachable | timeout | error
8export type PingStat = {
9 verdict: string
10 status: number
11 firstByteMs: number
12 at: number
13 detail: string
14}
15
16export type PanelData = {
17 online: boolean
18 direct: boolean
19 port: number
20 session: string // 'claude' or provider name
21 failoverProvider: string | null // null when failover has nobody to hand over to
22 failoverSince: number
23 nextClaudeTry: number
24 sessionsOnCustom: number
25 providers: Record<string, string>
26 aliases: Record<string, string>
27 failoverChain: string[]
28 // The router's own view of its switches; null when it is offline or too old to report them
29 switches: { failover: boolean; retry: boolean; proxy: boolean; proxyReady: boolean } | null
30 stats: Record<string, ProviderStat>
31 ping: Record<string, PingStat>
32 hints: string[] // things to act on: an older config, a provider the router skips
33 lang: 'zh' | 'en'
34 now: number
35}
36
37// What the last panel action came to: green when it did what was asked, yellow when it did not
38export type PanelNote = { text: string; ok: boolean }
39
40export type PanelUi = {
41 open: boolean
42 confirmingClaude: boolean
43 confirmingProxy?: boolean
44 pinging: boolean
45 // The switches every window shares stay folded away until asked for: the panel is for this window's model
46 showSettings?: boolean
47 note?: PanelNote
48}
49
50// One persona's picture from the config's "avatars": the image path as written, and the square of it that is the head
51// (centre x, centre y, side, in source pixels) when the config gives one
52export type AvatarEntry = { image: string; head: [number, number, number] | null }
53
54// full: the whole image fitted into the box; head: the head square fitted into it
55export type AvatarMode = 'full' | 'head'
56
57// unset: the persona has no entry in "avatars"; failed: ffmpeg or the image did not give pixels
58export type AvatarError = 'unset' | 'failed'
59
60// What the avatar pane draws: the last finished conversion, with everything it was made from, so what is drawn always
61// belongs together. cells (cols x rows, packed as a Raster's) is null while there is none or when it could not be made.
62export type AvatarView = {
63 name: string // 'claude' or a provider name
64 label: string // provider name and model id
65 entry: AvatarEntry | null
66 mode: AvatarMode
67 cols: number
68 rows: number
69 cells: string | null
70 error: AvatarError | null
71}
72
73declare module 'claude-code' {
74 interface PluginState {
75 'model-relay': {
76 panelUi: PanelUi
77 panelData: PanelData | null
78 avatarView: Shaped<AvatarView>
79 }
80 }
81}
82