From e6ee9af846392dae336dab2d4664c92f1f2af9bc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 18 Oct 2026 21:44:34 +0100 Subject: [PATCH] feat(frontdoor): route provider-qualified model names on every session A qualified model name was only resolved on a session that asked to route by name, so exposing another provider's models meant a launch flag or a router provider. The rule is now the naming scheme itself: a model name whose first slash-separated segment names a defined provider routes there on every session, with the request rewritten under the target's own model name, and a slash naming no provider (an aggregator's `vendor/model`) stays the session's own provider's business. A name for the provider the session is already on is served in place under its bare model name. Bare names keep their opt-in: only a session that routes by name (a router's included) resolves a virtual model's or a family's name, so nothing a session types bare is ever hijacked, and `--route-by-name` now means exactly that. `/model` follows: every session lists every provider's models qualified (its own provider's catalogue included), a provider session's rows replacing the built-in lineup and a provider-less session's following it; the virtual and family rows stay on the sessions that can serve them. The provider list the rows are built from is read through the launch's own filesystem port, so an invalid provider file lists no rows rather than failing the launch. One scan now runs for every routed request, so the name resolution's replay is carried into the `routes` matching that follows it; reading the original body twice left two consumers on one stream and hung the request. --- README.md | 2 +- docs/cli-reference.md | 2 +- docs/configuration-model.md | 2 +- src/frontdoor/providerRoute.test.ts | 18 ++++++++++++++++++ src/frontdoor/providerRoute.ts | 29 ++++++++++++++++++++++------- src/launcher.ts | 26 +++++++++++++++----------- src/launcher/modelPicker.test.ts | 28 +++++++++++++++++----------- src/launcher/modelPicker.ts | 13 +++++++------ src/launcherProvider.test.ts | 4 ++-- src/providersStore.ts | 2 +- 10 files changed, 85 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 2a956c78..4ef1c178 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ agent-shim run [@] [launch flags] [claude arguments] # --claude-version run exactly this installed Claude Code version # --delay wait this long after the launcher's own output before starting claude; --[no-]pause waits for a key press instead # --[no-]explain with @pool:, list every member the pick ranked and why, not only the one picked - # --[no-]route-by-name route provider-qualified model names through the models block to the provider that serves them + # --[no-]route-by-name also route bare virtual-model names through the models block (qualified names route on every session) # --native run the real claude with nothing from agent-shim applied; cannot be combined with the flags above claude @ ... # the same, once `agent-shim shim enable` has run ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 4185f754..262497c9 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -175,7 +175,7 @@ agent-shim run [@] [launch flags] [claude arguments] # --claude-version run exactly this installed Claude Code version # --delay wait this long after the launcher's own output before starting claude; --[no-]pause waits for a key press instead # --[no-]explain with @pool:, list every member the pick ranked and why, not only the one picked - # --[no-]route-by-name route provider-qualified model names through the models block to the provider that serves them + # --[no-]route-by-name also route bare virtual-model names through the models block (qualified names route on every session) # --native run the real claude with nothing from agent-shim applied; cannot be combined with the flags above claude @ ... # the same, once `agent-shim shim enable` has run ``` diff --git a/docs/configuration-model.md b/docs/configuration-model.md index 425939bb..23f69920 100644 --- a/docs/configuration-model.md +++ b/docs/configuration-model.md @@ -362,7 +362,7 @@ Some providers report their quota only through a usage endpoint of their own, ne ## Virtual models and tiers -A `models` block in `~/.agent-shim/config.json` defines virtual models over the providers' models: `agent-shim model add fast --mode soonest-reset --member z/glm-5.3 --member m/MiniMax-M3` names a choice, and its `mode` is how the choice is taken (`ordered` tries the members in order and falls to the fallback whose allowance is worth the most when one is spent, `most-allowance` serves from the member holding the most remaining allowance, `soonest-reset` spends what is about to reset, `spread` walks the members turn by turn, `adaptive` rules out what the request rules out and any member whose window's own pace so far (the allowance spent over the time since it opened, once a tenth of the period has passed) would exhaust it before the reset, and stays on the current model while it stands unless another's gain, as the share more of the allowance a steady burn would leave unspent, beats the share of a full one-million-token context that re-sending the conversation costs, `lowest-latency` and `highest-success` serve from the observed best, and `cheapest` ranks by the `model price` entries weighted by the tokens the session has actually been sending). A member names a provider model (`z/glm-5.3`), an account model (`acct:work/opus`), another virtual model (cycles are refused), or a whole model family (`family:`, every provider and account offering it). `agent-shim model tier opus --ref fast` points a Claude tier at one of these refs: for a session that routes by name the tier's model setting (`ANTHROPIC_DEFAULT_OPUS_MODEL` and its siblings) becomes the ref, which the door resolves per request, overriding that provider's own tier table for the tier, and `--clear` returns the tier to the table. A session that does not route by name keeps its provider's table, because only the door's name resolution can serve a ref. Nothing reads the block until a launch selects it, so a defined-but-unselected model changes nothing. `doctor` checks the block's own consistency: members naming no defined provider, virtual model or identity, cycles, a `cheapest` member with no prices, and a ref both hidden and shown. The block cascades: the global block is the shallowest layer, and a configuration profile, a directory rule and a committed `.agent-shim.json` carry the same `models` key in a layer form where any definition may be `null` to remove what the shallower layers set (`hide` and `show` replace wholesale), and the deepest layer naming a definition wins. The result is what a launch lists in `/model`, checks credentials for and applies tier pointers from; when a deeper layer changed it, the launch records the block it resolved in its session's registry entry and the door resolves that session's names against it, and when none did the door follows the global block live. A session that routes by name lists the catalogue in `/model`: its virtual models first, then one row per model family offered by more than one target (its description names every target, and selecting it lets the family's mode choose among them), then every provider's models qualified (`z / glm-5.3`), honouring the block's `hide` and `show` lists. A launch turns the block on with `launch.routeByName` (`--[no-]route-by-name`, `AGENT_SHIM_ROUTE_BY_NAME`, a profile's `--launch-route-by-name`, a directory rule or a committed file): a provider session then sends a provider-qualified model name (`m/glm-5.3`) or a virtual model's name to the provider that serves it instead of its own, and a `router` provider (a kind with no endpoint or credential) does the same whatever it launches with. A provider-less (OAuth) session routes qualified names the same way, and the names the block defines itself (a virtual model's, a `family:`), while every other bare name still goes to Anthropic, and its `/model` rows follow Claude Code's own rather than replacing them. The front door takes a virtual model's choice per request, through its mode over the live signals: the quota windows the session's identity last recorded for each member's provider, the latency, success and token mix each target's recent requests observed (seeded from the usage log at door start), and the block's prices, so an edited block or a spent window changes the next request's answer with no restart. Whatever the request named, the target receives its own model name: a qualified or virtual name is rewritten in the request's one field, exactly as a provider's own `routes` entry that names a model is. A launch with routing on also resolves every provider the block names before it starts, a `family:` member included (through every provider offering a target of the family), so a provider whose credential yields no token fails the launch naming it instead of the first request that names it. The door also reads what a request needs (an image, tools, extended thinking, the size of the context it re-sends) against what each candidate's family carries, which `adaptive` filters on. The same or a similarly named model offered by several providers and accounts is one family: its canonical key is the model's family and version with any variant after a `+` (`glm-5-1`, `claude-opus-5-5+high`), which `agent-shim model match` lists beside the pairs that are only similar. `model same z/glm-5.1 o/zai-glm-51` asserts that names the normaliser cannot connect are one model, `model distinct` asserts that two alike names are two, and `model family glm-5-1 --mode cheapest --no-vision --context 200000 --name o/odd-name` sets one family's own settings: how a row selecting the whole family chooses among its targets (`ordered` when absent), the names pinned into it, and what every one of its targets can do, which `adaptive` reads instead of a hand-tagged target. A variant never merges into its family by name alone: a member names the variant it means (`family:claude-opus-5-5+high`) and a bare key means the targets without one. These assertions and settings cascade with the rest of the block, accumulating across layers rather than replacing. `doctor` warns when a virtual model lists targets that look like one model, when a `same` assertion contradicts what the names say, and when a `family:` member names a family no offered target belongs to. +A `models` block in `~/.agent-shim/config.json` defines virtual models over the providers' models: `agent-shim model add fast --mode soonest-reset --member z/glm-5.3 --member m/MiniMax-M3` names a choice, and its `mode` is how the choice is taken (`ordered` tries the members in order and falls to the fallback whose allowance is worth the most when one is spent, `most-allowance` serves from the member holding the most remaining allowance, `soonest-reset` spends what is about to reset, `spread` walks the members turn by turn, `adaptive` rules out what the request rules out and any member whose window's own pace so far (the allowance spent over the time since it opened, once a tenth of the period has passed) would exhaust it before the reset, and stays on the current model while it stands unless another's gain, as the share more of the allowance a steady burn would leave unspent, beats the share of a full one-million-token context that re-sending the conversation costs, `lowest-latency` and `highest-success` serve from the observed best, and `cheapest` ranks by the `model price` entries weighted by the tokens the session has actually been sending). A member names a provider model (`z/glm-5.3`), an account model (`acct:work/opus`), another virtual model (cycles are refused), or a whole model family (`family:`, every provider and account offering it). `agent-shim model tier opus --ref fast` points a Claude tier at one of these refs: for a session that routes by name the tier's model setting (`ANTHROPIC_DEFAULT_OPUS_MODEL` and its siblings) becomes the ref, which the door resolves per request, overriding that provider's own tier table for the tier, and `--clear` returns the tier to the table. A session that does not route by name keeps its provider's table, because only the door's name resolution can serve a ref. Nothing reads the block until a launch selects it, so a defined-but-unselected model changes nothing. `doctor` checks the block's own consistency: members naming no defined provider, virtual model or identity, cycles, a `cheapest` member with no prices, and a ref both hidden and shown. The block cascades: the global block is the shallowest layer, and a configuration profile, a directory rule and a committed `.agent-shim.json` carry the same `models` key in a layer form where any definition may be `null` to remove what the shallower layers set (`hide` and `show` replace wholesale), and the deepest layer naming a definition wins. The result is what a launch lists in `/model`, checks credentials for and applies tier pointers from; when a deeper layer changed it, the launch records the block it resolved in its session's registry entry and the door resolves that session's names against it, and when none did the door follows the global block live. Every session lists the catalogue's qualified rows in `/model`: every provider's models under its name (`z / glm-5.3`), honouring the block's `hide` and `show` lists; a session that routes by name lists its virtual models first, then one row per model family offered by more than one target (its description names every target, and selecting it lets the family's mode choose among them). A provider-qualified model name (`m/glm-5.3`) is routed on every session, whatever it launched on and with no setting asked for: a slash whose first segment names a defined provider sends the request to that provider, rewritten under its own model name, and a slash naming no provider (an aggregator's `vendor/model`) stays the session's own provider's business. A launch turns the bare names on with `launch.routeByName` (`--[no-]route-by-name`, `AGENT_SHIM_ROUTE_BY_NAME`, a profile's `--launch-route-by-name`, a directory rule or a committed file): a provider session then sends a virtual model's or a family's name to the provider its mode picks instead of its own, and a `router` provider (a kind with no endpoint or credential) does the same whatever it launches with. A provider-less (OAuth) session routes the names the block defines itself (a virtual model's, a `family:`) the same way, while every other bare name still goes to Anthropic, and its `/model` rows follow Claude Code's own rather than replacing them. The front door takes a virtual model's choice per request, through its mode over the live signals: the quota windows the session's identity last recorded for each member's provider, the latency, success and token mix each target's recent requests observed (seeded from the usage log at door start), and the block's prices, so an edited block or a spent window changes the next request's answer with no restart. Whatever the request named, the target receives its own model name: a qualified or virtual name is rewritten in the request's one field, exactly as a provider's own `routes` entry that names a model is. A launch with routing on also resolves every provider the block names before it starts, a `family:` member included (through every provider offering a target of the family), so a provider whose credential yields no token fails the launch naming it instead of the first request that names it. The door also reads what a request needs (an image, tools, extended thinking, the size of the context it re-sends) against what each candidate's family carries, which `adaptive` filters on. The same or a similarly named model offered by several providers and accounts is one family: its canonical key is the model's family and version with any variant after a `+` (`glm-5-1`, `claude-opus-5-5+high`), which `agent-shim model match` lists beside the pairs that are only similar. `model same z/glm-5.1 o/zai-glm-51` asserts that names the normaliser cannot connect are one model, `model distinct` asserts that two alike names are two, and `model family glm-5-1 --mode cheapest --no-vision --context 200000 --name o/odd-name` sets one family's own settings: how a row selecting the whole family chooses among its targets (`ordered` when absent), the names pinned into it, and what every one of its targets can do, which `adaptive` reads instead of a hand-tagged target. A variant never merges into its family by name alone: a member names the variant it means (`family:claude-opus-5-5+high`) and a bare key means the targets without one. These assertions and settings cascade with the rest of the block, accumulating across layers rather than replacing. `doctor` warns when a virtual model lists targets that look like one model, when a `same` assertion contradicts what the names say, and when a `family:` member names a family no offered target belongs to. An `acct:` member is served by relaunch, one login per process: the door leaves a move request for the session's supervisor (a pool launch under supervision) and answers the request saying it is being moved, which resumes under that identity with the picked model; a session nothing supervises is told the command to run instead (`claude @work --resume`). `agent-shim model explain ` shows which member a virtual model's mode picks over the same signals, and why each candidate stands where it does. diff --git a/src/frontdoor/providerRoute.test.ts b/src/frontdoor/providerRoute.test.ts index 61a7331d..68364a97 100644 --- a/src/frontdoor/providerRoute.test.ts +++ b/src/frontdoor/providerRoute.test.ts @@ -130,6 +130,24 @@ describe("createProviderRouteResolver", () => { } }); + it("routes a provider-qualified model name on every session, whatever it launched on, with no flag asked or given", async () => { + const plain = (model: string, provider: string | undefined): RoutedRequest => ({ ...request("/v1/messages", JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: "hi" }] })), session: { identity: undefined, sessionId: undefined, provider, headroom: false, routeByName: false, projectId: undefined } }); + const resolve = resolver({ [`${PROVIDERS_DIR}/z.json`]: httpProvider, [`${PROVIDERS_DIR}/s.json`]: { displayName: "S", baseUrl: "https://api.synthetic.new/anthropic", credential: { sources: [{ literal: "s-token" }] } } }); + const withBlock = createProviderRouteResolver({ fs: fakeFs({ [`${PROVIDERS_DIR}/z.json`]: httpProvider }), providersDir: PROVIDERS_DIR, codexPorts: codexPorts(), directPort: () => OWN_PORT, env: { Z: "tok-from-z" }, credentials: fakeCredentials(), models: () => ({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }), targets: () => ["z/glm-5.3"] }); + // z naming s's model leaves z for s; a qualified name for the session's own provider is served right there under the bare name. + const cross = await resolve(plain("s/glm-5.2", "z")); + expect(cross.ok && cross.route.name).toBe("http:s"); + const own = await resolve(plain("z/glm-5.3", "z")); + expect(own.ok && own.route.name).toBe("http:z"); + // A slash alone is not a provider ref: a first segment no provider defines (an aggregator's `vendor/model`) stays the session's own provider's business. + const slashed = await resolve(plain("hf:zai-org/GLM-5.1", "z")); + expect(slashed.ok && slashed.route.name).toBe("http:z"); + // A bare name is never resolved without the session's opt-in, even one the block defines. + const bare = await withBlock(plain("fast", "z")); + expect(bare.ok && bare.route.name).toBe("http:z"); + if (bare.ok) expect(bare.modelChoice).toBeUndefined(); + }); + it("routes a provider-qualified model name from a provider-less (OAuth) session that asked to route by name onto that provider's route", async () => { const byName = (model: string, url = "/v1/messages"): RoutedRequest => ({ ...request(url, JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: "hi" }] })), diff --git a/src/frontdoor/providerRoute.ts b/src/frontdoor/providerRoute.ts index aeaedfbd..9031ac76 100644 --- a/src/frontdoor/providerRoute.ts +++ b/src/frontdoor/providerRoute.ts @@ -59,6 +59,8 @@ interface RoutedModel { /** What `resolveRoutedModel` reads from the door: the session's models block, the quota snapshot of its identity, what each target's recent requests observed, and the targets the providers offer (which families are formed from). */ interface RoutedModelDeps { readonly models?: (session: Readonly<{ readonly sessionId?: string }>) => ModelsConfig | undefined; + /** Whether a provider file defines this name: the qualification test for a slash in a model name, so a provider's own slashed model ids (an aggregator's `vendor/model`) are only ever treated as a cross-provider ref when the first segment really names a provider. */ + readonly definesProvider?: (name: string) => boolean; readonly usageSnapshotOf?: (identity: string | undefined) => UsageSnapshot | undefined; readonly observations?: (target: string) => TargetObservation | undefined; readonly targets?: () => readonly string[]; @@ -78,6 +80,7 @@ function resolveRoutedModel( providerName: string | undefined, session: Readonly<{ readonly identity?: string; readonly sessionId?: string }>, deps: Readonly, + byName: boolean, memory: Readonly>, rotations: Readonly>, needs: RequestNeeds | undefined, @@ -88,12 +91,12 @@ function resolveRoutedModel( if (qualified !== undefined) { const [provider, ...rest] = qualified.split("/"); const named = rest.join("/"); - return provider === undefined || named === "" ? undefined : { provider, model: withoutContextMarker(named), ref: qualified }; + return provider === undefined || named === "" || (deps.definesProvider !== undefined && !deps.definesProvider(provider)) ? undefined : { provider, model: withoutContextMarker(named), ref: qualified }; } const isFamily = model.startsWith(FAMILY_PREFIX); const virtual = isFamily ? undefined : models?.virtual?.[model]; - // A name the models block defines is the user's own, so it is routed on any session that asked to route by name, a provider-less (OAuth) one included; a bare name the block does not define stays Claude Code's own and is never resolved here. - if (virtual === undefined && !isFamily) { + // A bare name is only the models block's to resolve on a session that asked to route by name (a router's sessions included): every other session's bare names are its own provider's or Claude Code's own, and are never hijacked. A name the block does not define stays Claude Code's own even for those sessions. + if (!byName || (virtual === undefined && !isFamily)) { return undefined; } const declared = isFamily ? [model] : expandMembers(model, models); @@ -184,6 +187,14 @@ export function createProviderRouteResolver(deps: { const antigravityQuotaSources = new Map AntigravityQuotaMap | undefined>(); // The routing memory an antigravity provider's sessions keep their last model in: one per provider, held by the resolver, because the route is mounted afresh for every request and a memory inside the mount would forget between requests. const antigravityRoutingMemory = new Map>(); + /** Whether a provider file defines this name: an absent file and an unparseable one both answer no, exactly as the loop above treats an unparseable file as no routes rather than a fault. */ + const definesProvider = (name: string): boolean => { + try { + return loadProvider(deps.providersDir, name, deps.fs) !== undefined; + } catch { + return false; + } + }; // The routing memory a by-name session keeps its last chosen member in, and the rotation counter that keeps `spread` turning, both held by the resolver for the same reason: the route is mounted afresh for every request. const routingMemory = new Map(); const rotations = new Map(); @@ -368,9 +379,9 @@ export function createProviderRouteResolver(deps: { routes = undefined; isRouter = false; } - // A router resolves each request's model name through the models block to a target provider, whose own kind handling and credential then serve it: the same one-scan, one-rewrite shape the `routes` list below uses. A session that asked to route by name gets the same resolution on the provider it launched on: a qualified model name leaves that provider for the one that serves it. A provider-less (OAuth) session gets it too, but qualified names only: its bare names are Claude Code's own and still go to Anthropic, and the Remote Control family of paths is never a provider's to serve. - const routesByName = !nameResolved && (isRouter || current.session.routeByName) && !isRcSelfHostPath(path); - if (routesByName) { + // A router resolves each request's model name through the models block to a target provider, whose own kind handling and credential then serve it: the same one-scan, one-rewrite shape the `routes` list below uses. A qualified model name (a slash whose first segment names a defined provider) is resolved on every session, whatever it launched on: the picker offers every provider's models qualified, and a session that selects one is routed to the provider that serves it. Bare names are resolved only on a session that asked to route by name (a router's included): every other session's bare names are its own provider's business. The Remote Control family of paths is never a provider's to serve. + const resolvesNames = !nameResolved && !isRcSelfHostPath(path); + if (resolvesNames) { // Whole-body, because a resolved name is served under its target's own model name: the qualified or virtual name the session sent is rewritten in the one copy of the body this scan holds, exactly as a `routes` entry that names a model is. const scan = await scanRequestHead(current, true); if (scan.replayed === undefined) { @@ -396,8 +407,12 @@ export function createProviderRouteResolver(deps: { : `model "${account.model}" runs under identity "${account.identity}", which needs a relaunch this session cannot make; start it there with: claude @${account.identity} --resume`, }; } - const routed = scan.model === undefined ? undefined : resolveRoutedModel(scan.model, providerName, current.session, deps, routingMemory, rotations, needsOf(scan)); + const routed = scan.model === undefined ? undefined : resolveRoutedModel(scan.model, providerName, current.session, { ...deps, definesProvider }, isRouter || current.session.routeByName, routingMemory, rotations, needsOf(scan)); const requested = scan.model; + if (routed === undefined) { + // Nothing to resolve by name, and this scan consumed the original body: the request the `routes` matching below (or the serving at the end) reads is the replay, which carries the whole body. Reading the original again would see only its remainder, and leaving both scans attached to one stream is a hang. + current = scan.replayed; + } if (routed !== undefined) { routingMemory.set(current.session.sessionId ?? "", routed.ref); // A virtual model's or family's selection is a choice among alternatives, unlike a qualified name the session picked itself: only that choice is recorded for the response middleware. diff --git a/src/launcher.ts b/src/launcher.ts index 9397b3fc..c44fafe8 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -17,9 +17,9 @@ import { diagnosticLines, printDiagnosticLines, reportDiagnostics, type Diagnost import { holdBeforeLaunch, LAUNCH_CANCELLED_EXIT, type HoldOutcome, type HoldPort } from "./launcher/hold"; import type { Candidate } from "./usage/pick"; import { decisionRows, farmRow, noteRow, pinnedVersionRow, routingRows } from "./launcher/launchReport"; -import { modelPickerSettings, modelPickerSettingsFromRows, routedModelRows } from "./launcher/modelPicker"; -import { catalogueCachePath, parseCatalogueCache } from "./providerCatalogue"; -import { listProviders } from "./providersStore"; +import { modelPickerSettingsFromRows, routedModelRows } from "./launcher/modelPicker"; +import { catalogueCachePath, parseCatalogueCache, type CatalogueModel } from "./providerCatalogue"; +import { listProviderNames, loadProvider } from "./providersStore"; import type { ModelsConfig, Provider } from "./config/schema"; import { resolvePoolLaunch } from "./launcher/pool"; import { resumedContextTokens } from "./usage/resumeContext"; @@ -495,18 +495,22 @@ function decideLaunch(params: PrepareLaunchParams, readOnly: boolean): DecidedLa } const flags = loginlessProvider === undefined ? requestedFlags : { ...requestedFlags, remoteControl: false }; - // `/model` lists the provider's real models, which needs claude's `--settings`; a launch that passes its own keeps claude's names, since claude takes one value and a second replaces the first. - const catalogue = selectedProvider === undefined ? undefined : parseCatalogueCache(params.fs.readFileUtf8(catalogueCachePath(paths, selectedProvider.name))); - // A session that routes by model name (the flag, or a router provider) lists the whole catalogue instead: its virtual models first, then every provider's models qualified. + // `/model` lists every provider's models qualified, which needs claude's `--settings`; a launch that passes its own keeps claude's names, since claude takes one value and a second replaces the first. + // A session that routes by model name (the flag, or a router provider) lists the whole catalogue: its virtual models first, then every provider's models qualified. Every other session lists the same qualified rows without the virtual and family ones (a bare name it has not opted in to route by name cannot serve), so a session can always select another provider's model by its qualified name, which the door resolves whatever the session launched on. A provider session's rows replace the built-in Claude names; a provider-less (OAuth) session keeps them and appends. const routesByName = flags.routeByName || (selectedProvider !== undefined && isRouterProvider(selectedProvider.definition)); // The cascade's block when a cascade was read (a launch with a farm), else the global block alone; read through the launcher's own config port so a test sees its own filesystem. const globalModels = loadConfigFile(paths.globalConfigFile, GlobalConfigSchema, params.fs.readConfigFile)?.config.models; const models = launchCascade !== undefined && params.farm !== undefined ? cascadeModels : globalModels; - const pickerSettings = routesByName - ? modelPickerSettingsFromRows(routedModelRows(models ?? {}, listProviders(paths).map((entry): readonly [string, Provider] => [entry.name, entry.provider])), selectedProvider !== undefined) - : selectedProvider === undefined - ? undefined - : modelPickerSettings(selectedProvider.definition, catalogue?.models); + // The provider list is read through the launch's own filesystem port (a broken file lists no rows rather than failing the launch; `doctor` is where a broken file is reported), never the process's real one, so a test's fake filesystem is what decides. + const listedProviders = listProviderNames(fs.readdir, paths.providersDir).flatMap((name): readonly [string, Provider, readonly CatalogueModel[]][] => { + try { + const definition = loadProvider(paths.providersDir, name, fs); + return definition === undefined ? [] : [[name, definition, parseCatalogueCache(params.fs.readFileUtf8(catalogueCachePath(paths, name)))?.models ?? []]]; + } catch { + return []; + } + }); + const pickerSettings = modelPickerSettingsFromRows(routedModelRows(models ?? {}, listedProviders, routesByName), selectedProvider !== undefined); const callerSettings = hasSettingsArgument(parsedArgv.rest); if (pickerSettings !== undefined && callerSettings) { notes.push("--settings was passed, so /model keeps Claude Code's own model names"); diff --git a/src/launcher/modelPicker.test.ts b/src/launcher/modelPicker.test.ts index 39683517..b0983264 100644 --- a/src/launcher/modelPicker.test.ts +++ b/src/launcher/modelPicker.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import type { CatalogueModel } from "../providerCatalogue"; import type { Provider, ProviderCatalogue } from "../config/schema"; import { modelPickerSettings, modelPickerSettingsFromRows, providerModelRows, routedModelRows } from "./modelPicker"; @@ -171,15 +172,15 @@ describe("modelPickerSettings", () => { }); it("appends the rows after Claude Code's own for a provider-less session that routes by name, whose bare names still reach Anthropic", () => { - const rows = routedModelRows({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }, [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" })] as const]); + const rows = routedModelRows({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }, [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" }), []] as const]); expect(JSON.parse(modelPickerSettingsFromRows(rows, false) ?? "")).toMatchObject({ modelPicker: { replaceBuiltInOptions: false } }); }); }); describe("routedModelRows", () => { - const providers: readonly (readonly [string, Provider])[] = [ - ["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" })], - ["m", http({ ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.7" })], + const providers: readonly (readonly [string, Provider, readonly CatalogueModel[]])[] = [ + ["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" }), []], + ["m", http({ ANTHROPIC_DEFAULT_SONNET_MODEL: "MiniMax-M2.7" }), []], ]; it("lists the virtual models first, then every provider's models qualified with the provider's name", () => { @@ -190,9 +191,9 @@ describe("routedModelRows", () => { }); it("lists one row per family offered by more than one target between the virtual models and the qualified rows, naming every target in its description", () => { - const two: readonly (readonly [string, Provider])[] = [ - ["z", http({ ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5.1" })], - ["s", http({ ANTHROPIC_DEFAULT_SONNET_MODEL: "hf:zai-org/GLM-5.1" })], + const two: readonly (readonly [string, Provider, readonly CatalogueModel[]])[] = [ + ["z", http({ ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5.1" }), []], + ["s", http({ ANTHROPIC_DEFAULT_SONNET_MODEL: "hf:zai-org/GLM-5.1" }), []], ]; const rows = routedModelRows({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.1"] } }, families: { "glm-5-1": { mode: "cheapest" } } }, two); expect(rows.map((row) => row.model)).toEqual(["fast", "family:glm-5-1", "z/glm-5.1", "s/hf:zai-org/GLM-5.1"]); @@ -200,14 +201,19 @@ describe("routedModelRows", () => { }); it("lists no row for a family one target offers, and honours hide for a family row", () => { - const one: readonly (readonly [string, Provider])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" })]]; + const one: readonly (readonly [string, Provider, readonly CatalogueModel[]])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" }), []]]; expect(routedModelRows({}, one).map((row) => row.model)).toEqual(["z/glm-5.3"]); - const two: readonly (readonly [string, Provider])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" })], ["s", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "hf:zai-org/GLM-5.1" })]]; + const two: readonly (readonly [string, Provider, readonly CatalogueModel[]])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" }), []], ["s", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "hf:zai-org/GLM-5.1" }), []]]; expect(routedModelRows({ hide: ["family:glm-5-1"] }, two).map((row) => row.model)).not.toContain("family:glm-5-1"); }); + it("omits the bare virtual and family rows for a session that has not asked to route by name, keeping the qualified rows every session can serve", () => { + const rows = routedModelRows({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.1"] } }, families: { "glm-5-1": { mode: "cheapest" } } }, [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" }), []], ["s", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "hf:zai-org/GLM-5.1" }), []]], false); + expect(rows.map((row) => row.model)).toEqual(["z/glm-5.1", "s/hf:zai-org/GLM-5.1"]); + }); + it("keeps a virtual model's row when no member names a tier, handled as a sonnet, and one over a family takes its targets' handling", () => { - const offered: readonly (readonly [string, Provider])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" })], ["s", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "hf:zai-org/GLM-5.1" })]]; + const offered: readonly (readonly [string, Provider, readonly CatalogueModel[]])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" }), []], ["s", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "hf:zai-org/GLM-5.1" }), []]]; const rows = routedModelRows({ virtual: { unknown: { mode: "ordered", members: ["ghost/x"] }, across: { mode: "ordered", members: ["family:glm-5-1"] } } }, offered); expect(rows.find((row) => row.model === "unknown")?.behavesAs).toBe("claude-sonnet-5-5"); expect(rows.find((row) => row.model === "across")?.behavesAs).toBe("claude-opus-5-5"); @@ -219,7 +225,7 @@ describe("routedModelRows", () => { }); it("keeps a virtual model's row when the provider's default-model env holds the same value as its tier default", () => { - const rows = routedModelRows({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }, [["z", http({ ANTHROPIC_MODEL: "glm-5.3", ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" })] as const]); + const rows = routedModelRows({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }, [["z", http({ ANTHROPIC_MODEL: "glm-5.3", ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" }), []] as const]); expect(rows.map((row) => row.model)).toContain("fast"); }); }); diff --git a/src/launcher/modelPicker.ts b/src/launcher/modelPicker.ts index 414299bb..4f72dcaf 100644 --- a/src/launcher/modelPicker.ts +++ b/src/launcher/modelPicker.ts @@ -155,25 +155,26 @@ export function providerModelRows(provider: Provider, catalogue: readonly Catalo /** * The rows a session that routes by model name offers: its virtual models first (the mode in the description), then one row per model family offered by more than one target (every provider and account offering it in the description; selecting it lets the family's mode choose among them), then every provider's models grouped under the provider's name, honouring the block's `hide` and `show` lists. A virtual or family row borrows the handling of the first target it can serve from that has a row of its own, since the provider's own tier table says how that model is handled; one with no such target is handled as a sonnet. */ -export function routedModelRows(models: ModelsConfig, providers: readonly (readonly [string, Provider])[]): readonly ModelPickerRow[] { +export function routedModelRows(models: ModelsConfig, providers: readonly (readonly [string, Provider, readonly CatalogueModel[]])[], bare = true): readonly ModelPickerRow[] { const hidden = new Set(models.hide ?? []); const shown = models.show === undefined ? undefined : new Set(models.show); const visible = (model: string): boolean => (shown === undefined ? !hidden.has(model) : shown.has(model)); - const concrete = providers.flatMap(([name, provider]) => providerModelRows(provider).map((row) => ({ ...row, model: `${name}/${row.model}`, label: `${name} / ${row.label}`, description: `${name}: ${row.description}` }))); + const concrete = providers.flatMap(([name, provider, catalogue]) => providerModelRows(provider, catalogue).map((row) => ({ ...row, model: `${name}/${row.model}`, label: `${name} / ${row.label}`, description: `${name}: ${row.description}` }))); const concreteByRef = new Map(concrete.map((row) => [row.model, row])); const families = buildFamilies([...concrete.map((row) => row.model), ...accountRefsOf(models)], models); const handlingOf = (refs: readonly string[]): string => refs.map((ref) => concreteByRef.get(ref)?.behavesAs).find((behavesAs) => behavesAs !== undefined) ?? TIER_BEHAVES_AS.sonnet; - const virtual = Object.entries(models.virtual ?? {}).flatMap(([name, definition]) => { + // A session that has not asked to route by name cannot serve a bare virtual or family name (its bare names are its own provider's or Claude Code's own), so only a session that asked lists those rows; the qualified concrete rows are routable everywhere. + const virtual = bare ? Object.entries(models.virtual ?? {}).flatMap(([name, definition]) => { if (!visible(name)) { return []; } const targets = expandFamilyMembers(safeExpand(name, models), families); return [{ model: name, label: name, description: `Virtual (${definition.mode})`, behavesAs: handlingOf(targets) }]; - }); - const familyRows = sharedFamilies(families).flatMap((family) => { + }) : []; + const familyRows = bare ? sharedFamilies(families).flatMap((family) => { const member = `${FAMILY_PREFIX}${family.key}`; return visible(member) ? [{ model: member, label: family.key, description: `Family (${familyMode(family.settings)}): ${family.refs.join(", ")}`, behavesAs: handlingOf(family.refs) }] : []; - }); + }) : []; return [...virtual, ...familyRows, ...concrete.filter((row) => visible(row.model))]; } diff --git a/src/launcherProvider.test.ts b/src/launcherProvider.test.ts index da3956f9..7099d2eb 100644 --- a/src/launcherProvider.test.ts +++ b/src/launcherProvider.test.ts @@ -491,7 +491,7 @@ describe("runLauncher /model picker", () => { const { args } = spawnedWith(codexProvider, ["--print"]); expect(args?.filter((token) => token === "--settings")).toHaveLength(1); - expect(settingsIn(args)).toMatchObject({ modelPicker: { replaceBuiltInOptions: true, options: [{ model: "big" }, { model: "mid" }, { model: "small" }] } }); + expect(settingsIn(args)).toMatchObject({ modelPicker: { replaceBuiltInOptions: true, options: [{ model: "p/big" }, { model: "p/mid" }, { model: "p/small" }] } }); }); it("carries a supervised launch's hooks and the picker in the same --settings value", () => { @@ -509,7 +509,7 @@ describe("runLauncher /model picker", () => { it("lists the models the door last fetched, after the provider's own filters", () => { const { args } = spawnedWith(catalogued, ["--print"], undefined, { [cacheFile]: cache }); - expect(settingsIn(args)).toMatchObject({ modelPicker: { replaceBuiltInOptions: true, options: [{ model: "glm-4" }, { model: "glm-5" }] } }); + expect(settingsIn(args)).toMatchObject({ modelPicker: { replaceBuiltInOptions: true, options: [{ model: "p/glm-4" }, { model: "p/glm-5" }] } }); }); it("keeps Claude Code's own picker until a catalogue has been fetched", () => { diff --git a/src/providersStore.ts b/src/providersStore.ts index 38219d54..7a025fbc 100644 --- a/src/providersStore.ts +++ b/src/providersStore.ts @@ -211,7 +211,7 @@ export function listProviders(paths: LayoutPaths, onInvalid: (name: string, mess /** * The names of every provider definition under `providersDir`, sorted. `readdir` is injected so the launcher's unknown-provider error can name the known providers through its own `FsPort` in tests rather than a real directory listing. */ -function listProviderNames(readdir: (dir: string) => readonly string[], providersDir: string): readonly string[] { +export function listProviderNames(readdir: (dir: string) => readonly string[], providersDir: string): readonly string[] { let entries: readonly string[]; try { entries = readdir(providersDir);