diff --git a/README.md b/README.md index 4ef1c178..687d1357 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ agent-shim profile set [--category =]... [--entry [--extends ]... [--no-extends] [--description | --no-description] [--json] agent-shim profile set [--[no-]launch-skip-permissions] [--[no-]launch-remote-control] [--[no-]launch-headroom] [--[no-]launch-track-usage] agent-shim profile set [--launch-claude-version | --no-launch-claude-version] [--json] [--json] -agent-shim profile set [--launch-delay | --no-launch-delay] [--[no-]launch-pause] [--[no-]launch-explain] [--[no-]launch-route-by-name] [--json] +agent-shim profile set [--launch-delay | --no-launch-delay] [--[no-]launch-pause] [--[no-]launch-explain] [--json] agent-shim profile set [--launch-provider | --no-launch-provider] [--json] agent-shim profile list [--json] agent-shim profile show [--json] @@ -196,7 +196,6 @@ 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 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 262497c9..19376ec2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -18,7 +18,6 @@ The full flag/command reference table, the complete command list, `configure`'s | **Claude Code version** | `agent-shim profile set --launch-claude-version ` (or `--no-launch-claude-version`); a `launch.claudeVersion` key in `~/.agent-shim/config.json` or any cascade layer | `claude --claude-version ` / `AGENT_SHIM_CLAUDE_VERSION= claude` | rule's inline `"launch": { "claudeVersion": "2.1.220" }` field; or `.agent-shim.json`'s `"launch"` | | **Models (cascade)** | `models` in a configuration profile, directory rule or committed `.agent-shim.json`; any definition may be `null` to remove what the shallower layers set | not applicable (the global `models` block is the first layer) | the committed file's `models` merges like any other layer | | **Launch delay and pause** | `agent-shim profile set --launch-delay ` (or `--no-launch-delay`) and `--launch-pause` (or `--no-launch-pause`); `launch.delay` and `launch.pause` keys in `~/.agent-shim/config.json` or any cascade layer | `claude --delay ` / `claude --pause` / `claude --no-pause` / `AGENT_SHIM_DELAY=5s claude` / `AGENT_SHIM_PAUSE=true claude` | a rule's inline `"launch": { "delay": "5s" }` field; or `.agent-shim.json`'s `"launch"` | -| **Route by model name** | `agent-shim profile set --launch-route-by-name` (or `--no-launch-route-by-name`); a `launch.routeByName` key in `~/.agent-shim/config.json` or any cascade layer | `claude --route-by-name` / `claude --no-route-by-name` / `AGENT_SHIM_ROUTE_BY_NAME=true claude` | a rule's inline `"launch": { "routeByName": true }` field; or `.agent-shim.json`'s `"launch"` | | **Explain a pool pick** | `agent-shim profile set --launch-explain` (or `--no-launch-explain`); a `launch.explain` key in `~/.agent-shim/config.json` or any cascade layer | `claude --explain` / `claude --no-explain` / `AGENT_SHIM_EXPLAIN=true claude` | a rule's inline `"launch": { "explain": true }` field; or `.agent-shim.json`'s `"launch"` | | **Usage tracking** | `agent-shim profile set --launch-track-usage` (or `--no-launch-track-usage`); a `launch.trackUsage` key in `~/.agent-shim/config.json` or any cascade layer | `claude --track-usage` / `claude --no-track-usage` / `AGENT_SHIM_TRACK_USAGE=true claude` | rule's inline `"launch": { "trackUsage": true }` field; or `.agent-shim.json`'s `"launch"` | | **Identity credential** | `agent-shim identity set --credential [--credential-target oauthToken]` (in its `identity.json`); `--no-credential` returns it to its stored login | not applicable | not applicable: a credential belongs to the identity, not a directory | @@ -71,7 +70,7 @@ agent-shim profile set [--category =]... [--entry [--extends ]... [--no-extends] [--description | --no-description] agent-shim profile set [--[no-]launch-skip-permissions] [--[no-]launch-remote-control] [--[no-]launch-headroom] [--[no-]launch-track-usage] agent-shim profile set [--launch-claude-version | --no-launch-claude-version] -agent-shim profile set [--launch-delay | --no-launch-delay] [--[no-]launch-pause] [--[no-]launch-explain] [--[no-]launch-route-by-name] +agent-shim profile set [--launch-delay | --no-launch-delay] [--[no-]launch-pause] [--[no-]launch-explain] agent-shim profile set [--launch-provider | --no-launch-provider] agent-shim profile list [--json] agent-shim profile show [--json] @@ -175,7 +174,6 @@ 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 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 23f69920..c3ac1f84 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. 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. +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: 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. 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; 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 bare name is routed the same way when only the models block defines it: a virtual model's or a family's name goes to the provider its mode picks, on every session and for a `router` provider (a kind with no endpoint or credential) as well. A bare name the session's own provider serves itself stays that provider's, because the launch chose it for its own names; a name neither the block nor the provider defines stays where it was sent. A provider-less (OAuth) session keeps every other bare name on 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 a models block 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/schema/ConfigProfile.schema.json b/schema/ConfigProfile.schema.json index 35796d4e..7916c765 100644 --- a/schema/ConfigProfile.schema.json +++ b/schema/ConfigProfile.schema.json @@ -134,9 +134,6 @@ "pause": { "type": "boolean" }, - "routeByName": { - "type": "boolean" - }, "explain": { "type": "boolean" } diff --git a/schema/DirectoryRule.schema.json b/schema/DirectoryRule.schema.json index 8beddeb5..ba064241 100644 --- a/schema/DirectoryRule.schema.json +++ b/schema/DirectoryRule.schema.json @@ -131,9 +131,6 @@ "pause": { "type": "boolean" }, - "routeByName": { - "type": "boolean" - }, "explain": { "type": "boolean" } diff --git a/schema/DirectoryRules.schema.json b/schema/DirectoryRules.schema.json index fd09c044..331baf49 100644 --- a/schema/DirectoryRules.schema.json +++ b/schema/DirectoryRules.schema.json @@ -139,9 +139,6 @@ "pause": { "type": "boolean" }, - "routeByName": { - "type": "boolean" - }, "explain": { "type": "boolean" } diff --git a/schema/GlobalConfig.schema.json b/schema/GlobalConfig.schema.json index aafeb51d..33810d5e 100644 --- a/schema/GlobalConfig.schema.json +++ b/schema/GlobalConfig.schema.json @@ -132,9 +132,6 @@ "pause": { "type": "boolean" }, - "routeByName": { - "type": "boolean" - }, "explain": { "type": "boolean" } diff --git a/src/config/schema.ts b/src/config/schema.ts index 0b3bcebe..0bb36d7c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -107,7 +107,6 @@ const LaunchSchema = z.strictObject({ /** Whether the launcher waits for a key press after its own output before it starts claude. Takes the place of `delay` when both are set. Applies only when a terminal is watching. */ pause: z.boolean().optional(), /** Whether a provider session routes provider-qualified model names (`z/glm-5.3`, `auto`) through the `models` block to the provider that serves them, instead of sending every request to the launch provider. */ - routeByName: z.boolean().optional(), /** Whether the launcher's report, for a launch that picks from a pool, lists every member the pick ranked with why each stands where it does, and not only the member it picked. */ explain: z.boolean().optional(), }); diff --git a/src/configProfiles.test.ts b/src/configProfiles.test.ts index 2739893a..c09be429 100644 --- a/src/configProfiles.test.ts +++ b/src/configProfiles.test.ts @@ -176,22 +176,22 @@ describe("configProfiles", () => { it("merges headroom with the other launch flags", () => { createProfile(paths, "base"); setProfileLaunchFlags(paths, "base", { skipPermissions: true }); - expect(setProfileLaunchFlags(paths, "base", { headroom: true, routeByName: false }).launch).toEqual({ + expect(setProfileLaunchFlags(paths, "base", { headroom: true }).launch).toEqual({ skipPermissions: true, - headroom: true, routeByName: false, + headroom: true, }); }); it("stores trackUsage beside the other launch flags", () => { createProfile(paths, "base"); - setProfileLaunchFlags(paths, "base", { headroom: true, routeByName: false }); - expect(setProfileLaunchFlags(paths, "base", { trackUsage: true }).launch).toEqual({ headroom: true, routeByName: false, trackUsage: true }); + setProfileLaunchFlags(paths, "base", { headroom: true }); + expect(setProfileLaunchFlags(paths, "base", { trackUsage: true }).launch).toEqual({ headroom: true, trackUsage: true }); }); it("stores a delay and a pause beside the other launch flags", () => { createProfile(paths, "base"); - setProfileLaunchFlags(paths, "base", { headroom: true, routeByName: false }); - expect(setProfileLaunchFlags(paths, "base", { delay: "5s", pause: false }).launch).toEqual({ headroom: true, routeByName: false, delay: "5s", pause: false }); + setProfileLaunchFlags(paths, "base", { headroom: true }); + expect(setProfileLaunchFlags(paths, "base", { delay: "5s", pause: false }).launch).toEqual({ headroom: true, delay: "5s", pause: false }); }); it("stores explain beside the other launch flags", () => { diff --git a/src/configProfiles.ts b/src/configProfiles.ts index 8ca6f7ab..e2e25cbb 100644 --- a/src/configProfiles.ts +++ b/src/configProfiles.ts @@ -121,8 +121,6 @@ export function registerProfileCommand(program: Command, deps: CommandDeps): voi .option("--no-launch-pause", "Launches under this profile start claude without waiting for a key press.") .option("--launch-explain", "Launches under this profile that pick from a pool list every member the pick ranked and why, in the launcher's report.") .option("--no-launch-explain", "Launches under this profile name only the member a pool pick chose.") - .option("--launch-route-by-name", "Launches under this profile route provider-qualified model names through the models block to the provider that serves them.") - .option("--no-launch-route-by-name", "Launches under this profile send every request to the launch provider.") .option("--json", "Print the result as JSON.") .action(async (name: string, allOptions: ProfileSetOptions) => { const { json, ...options } = allOptions; diff --git a/src/configProfilesStore.ts b/src/configProfilesStore.ts index 74c97abe..7f0a660b 100644 --- a/src/configProfilesStore.ts +++ b/src/configProfilesStore.ts @@ -205,7 +205,6 @@ export interface ProfileChange { readonly launchDelay?: string | false; readonly launchPause?: boolean; readonly launchExplain?: boolean; - readonly launchRouteByName?: boolean; } /** Applies every field `change` names to profile `name`, returning whether any was given. The one implementation `profile set` and the typed API's `config.profile.set` share. */ @@ -254,9 +253,6 @@ export function applyProfileChange(paths: LayoutPaths, name: string, change: Pro if (change.launchExplain !== undefined) { launchPatch.explain = change.launchExplain; } - if (change.launchRouteByName !== undefined) { - launchPatch.routeByName = change.launchRouteByName; - } if (Object.keys(launchPatch).length > 0) { setProfileLaunchFlags(paths, name, launchPatch); touched = true; diff --git a/src/frontdoor/antigravityMount.test.ts b/src/frontdoor/antigravityMount.test.ts index 2f94c810..975623c8 100644 --- a/src/frontdoor/antigravityMount.test.ts +++ b/src/frontdoor/antigravityMount.test.ts @@ -121,7 +121,7 @@ describe("createAntigravityRouteMount", () => { identity: undefined, sessionId: undefined, provider: undefined, - headroom: false, routeByName: false, + headroom: false, projectId: undefined, }, }; @@ -157,7 +157,7 @@ describe("createAntigravityRouteMount", () => { identity: undefined, sessionId: undefined, provider: undefined, - headroom: false, routeByName: false, + headroom: false, projectId: undefined, }, }; @@ -217,7 +217,7 @@ describe("a tier alias the translator resolved", () => { identity: undefined, sessionId: undefined, provider: undefined, - headroom: false, routeByName: false, + headroom: false, projectId: undefined, }, }; diff --git a/src/frontdoor/commands.test.ts b/src/frontdoor/commands.test.ts index 166ec5c6..9ed65962 100644 --- a/src/frontdoor/commands.test.ts +++ b/src/frontdoor/commands.test.ts @@ -281,7 +281,7 @@ describe("realStatOwnExecutable", () => { describe("the Remote Control observing resolver", () => { const route: FrontDoorRoute = { name: "http:z", headroomEligible: true, headroomUpstream: undefined, serve: async () => { await Promise.resolve(); } }; - const original = { method: "POST", url: "/v1/messages", headers: {}, body: undefined, signal: new AbortController().signal, session: { identity: undefined, sessionId: "s-1", provider: "z", headroom: true, routeByName: true, projectId: undefined } } as unknown as RoutedRequest; + const original = { method: "POST", url: "/v1/messages", headers: {}, body: undefined, signal: new AbortController().signal, session: { identity: undefined, sessionId: "s-1", provider: "z", headroom: true, projectId: undefined } } as unknown as RoutedRequest; it("hands on the request a routing scan replayed, because the original's body was consumed by the scan and only the replay can still be read", async () => { const replayed = { ...original, url: "/providers/z/v1/messages" }; diff --git a/src/frontdoor/configApi.test.ts b/src/frontdoor/configApi.test.ts index dbf79ccb..976e9db1 100644 --- a/src/frontdoor/configApi.test.ts +++ b/src/frontdoor/configApi.test.ts @@ -141,9 +141,9 @@ describe("config API: configuration profiles", () => { name: "client", extends: ["base"], category: { history: false }, - launch: { headroom: true, routeByName: false, provider: "z", claudeVersion: "2.1.220" }, + launch: { headroom: true, provider: "z", claudeVersion: "2.1.220" }, }); - expect(updated.value).toMatchObject({ extends: ["base"], categories: { history: false }, launch: { headroom: true, routeByName: false, provider: "z", claudeVersion: "2.1.220" } }); + expect(updated.value).toMatchObject({ extends: ["base"], categories: { history: false }, launch: { headroom: true, provider: "z", claudeVersion: "2.1.220" } }); const cleared = await api.profile.set({ name: "client", extends: false, description: false, launch: { provider: false } }); expect(cleared.value.extends).toBeUndefined(); diff --git a/src/frontdoor/configApi.ts b/src/frontdoor/configApi.ts index 42ca7b7a..b79929f5 100644 --- a/src/frontdoor/configApi.ts +++ b/src/frontdoor/configApi.ts @@ -281,7 +281,6 @@ export function createConfigApiRouter(deps: ConfigApiDeps) { ...(launch?.delay === undefined ? {} : { launchDelay: launch.delay }), ...(launch?.pause === undefined ? {} : { launchPause: launch.pause }), ...(launch?.explain === undefined ? {} : { launchExplain: launch.explain }), - ...(launch?.routeByName === undefined ? {} : { launchRouteByName: launch.routeByName }), }); return { action: "updated" as const, kind: "profile" as const, name: input.name, value: profileValue(input.name, paths) }; }), diff --git a/src/frontdoor/configSchemas.ts b/src/frontdoor/configSchemas.ts index 055d3cdf..07ec2586 100644 --- a/src/frontdoor/configSchemas.ts +++ b/src/frontdoor/configSchemas.ts @@ -126,7 +126,6 @@ export const ConfigProfileSetInputSchema = z.strictObject({ delay: z.union([z.string().regex(DURATION_RE), z.literal(false)]).optional(), pause: z.boolean().optional(), explain: z.boolean().optional(), - routeByName: z.boolean().optional(), }) .optional(), }); diff --git a/src/frontdoor/controlApi.e2e.test.ts b/src/frontdoor/controlApi.e2e.test.ts index 3c006d68..321027a5 100644 --- a/src/frontdoor/controlApi.e2e.test.ts +++ b/src/frontdoor/controlApi.e2e.test.ts @@ -211,7 +211,7 @@ describe("the door's typed API with the control plane mounted beside Remote Cont // The launch resolution rides the same mount: the launcher's decision for a directory, without a spawn. const resolved = await api.launch.resolve({ path: `${FAKE_HOME}/work`, argv: ["@work", "--headroom", "--print"] }); - expect(resolved).toMatchObject({ decision: { identity: "work", identitySource: "argv" }, flags: { headroom: true, routeByName: false }, bin: discovered.path, routing: { frontDoor: true, headroom: true, routeByName: false }, warnings: [] }); + expect(resolved).toMatchObject({ decision: { identity: "work", identitySource: "argv" }, flags: { headroom: true }, bin: discovered.path, routing: { frontDoor: true, headroom: true }, warnings: [] }); expect(resolved.args).toEqual(["--print"]); // A caller without this generation's control token is refused by the control plane exactly as the Remote Control surface refuses it. diff --git a/src/frontdoor/launchApi.test.ts b/src/frontdoor/launchApi.test.ts index 0ca211b2..bc46db39 100644 --- a/src/frontdoor/launchApi.test.ts +++ b/src/frontdoor/launchApi.test.ts @@ -26,12 +26,12 @@ const RESOLUTION: LaunchResolution = { configDir: "/home/testuser/.agent-shim/identities/work", configProfileSource: "none", }, - flags: { skipPermissions: false, remoteControl: false, headroom: true, trackUsage: false, pause: false, explain: false, routeByName: false, delayMs: 0 }, + flags: { skipPermissions: false, remoteControl: false, headroom: true, trackUsage: false, pause: false, explain: false, delayMs: 0 }, claudeVersion: { version: "2.1.0", source: "flag" }, bin: "/home/testuser/.local/share/claude/versions/2.1.0", args: ["--print"], credential: { subject: "identity", name: "work", summary: { target: "oauthToken", sources: [{ kind: "env", variable: "WORK_TOKEN" }] } }, - routing: { frontDoor: true, headroom: true, routeByName: false }, + routing: { frontDoor: true, headroom: true }, environment: ["ANTHROPIC_CUSTOM_HEADERS", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CONFIG_DIR", "HTTPS_PROXY"], }; diff --git a/src/frontdoor/launchSchemas.ts b/src/frontdoor/launchSchemas.ts index 929c0535..37a8c823 100644 --- a/src/frontdoor/launchSchemas.ts +++ b/src/frontdoor/launchSchemas.ts @@ -48,7 +48,6 @@ const LaunchFlagsSchema = z.strictObject({ trackUsage: z.boolean(), pause: z.boolean(), explain: z.boolean(), - routeByName: z.boolean(), /** How long the launch waits after its own output before it starts claude, in milliseconds. */ delayMs: z.number(), }); @@ -64,7 +63,7 @@ export const LaunchResolveOutputSchema = z.strictObject({ args: z.readonly(z.array(z.string())), /** The credential the launch would authenticate with, by target and source kinds. Absent when none applies. */ credential: z.strictObject({ subject: z.enum(["provider", "identity"]), name: z.string(), summary: CredentialSummarySchema }).optional(), - routing: z.strictObject({ frontDoor: z.boolean(), headroom: z.boolean(), routeByName: z.boolean() }), + routing: z.strictObject({ frontDoor: z.boolean(), headroom: z.boolean() }), /** The names of the environment variables the launch would set or unset, sorted. Values are not reported. */ environment: z.readonly(z.array(z.string())), warnings: z.readonly(z.array(z.string())), diff --git a/src/frontdoor/pipeline.test.ts b/src/frontdoor/pipeline.test.ts index c18cb5c3..6fc60d02 100644 --- a/src/frontdoor/pipeline.test.ts +++ b/src/frontdoor/pipeline.test.ts @@ -100,7 +100,7 @@ function deps(resolution: RouteResolution, observers: readonly ResponseObserver[ describe("createRoutedResponse", () => { it("applies backpressure: a write that the socket refuses waits for its drain", async () => { const response = new FakeServerResponse(true); - const routed = createRoutedResponse(response as unknown as PipelineRequest["response"], { deps: deps({ ok: true, route: recordingRoute() }).deps, servedPath: "/v1/messages", session: { identity: "work", sessionId: "s", provider: undefined, headroom: false, routeByName: false, projectId: undefined }, route: "test", request: { method: "POST", path: "/v1/messages", receivedAt: 0, viaHeadroom: false, thread: { lineage: undefined }, modelChoice: { value: undefined }, requestedModel: { value: undefined } } }); + const routed = createRoutedResponse(response as unknown as PipelineRequest["response"], { deps: deps({ ok: true, route: recordingRoute() }).deps, servedPath: "/v1/messages", session: { identity: "work", sessionId: "s", provider: undefined, headroom: false, projectId: undefined }, route: "test", request: { method: "POST", path: "/v1/messages", receivedAt: 0, viaHeadroom: false, thread: { lineage: undefined }, modelChoice: { value: undefined }, requestedModel: { value: undefined } } }); routed.start(HTTP_STATUS.ok, {}); let resolved = false; const write = routed.write("chunk").then(() => { @@ -116,7 +116,7 @@ describe("createRoutedResponse", () => { it("settles a backpressured write when the client goes away, so a route is never left waiting on a dead socket", async () => { const response = new FakeServerResponse(true); - const routed = createRoutedResponse(response as unknown as PipelineRequest["response"], { deps: deps({ ok: true, route: recordingRoute() }).deps, servedPath: "/v1/messages", session: { identity: "work", sessionId: "s", provider: undefined, headroom: false, routeByName: false, projectId: undefined }, route: "test", request: { method: "POST", path: "/v1/messages", receivedAt: 0, viaHeadroom: false, thread: { lineage: undefined }, modelChoice: { value: undefined }, requestedModel: { value: undefined } } }); + const routed = createRoutedResponse(response as unknown as PipelineRequest["response"], { deps: deps({ ok: true, route: recordingRoute() }).deps, servedPath: "/v1/messages", session: { identity: "work", sessionId: "s", provider: undefined, headroom: false, projectId: undefined }, route: "test", request: { method: "POST", path: "/v1/messages", receivedAt: 0, viaHeadroom: false, thread: { lineage: undefined }, modelChoice: { value: undefined }, requestedModel: { value: undefined } } }); routed.start(HTTP_STATUS.ok, {}); let resolved = false; const write = routed.write("chunk").then(() => { @@ -135,7 +135,7 @@ describe("serveRouted", () => { const route = recordingRoute(); const { request } = pipelineRequest({ [IDENTITY_HEADER]: "work", [SESSION_HEADER]: "session-9", [HEADROOM_FLAG_HEADER]: "1", authorization: "Bearer tok" }); await serveRouted(request, deps({ ok: true, route }).deps); - expect(route.seen[0]?.session).toEqual({ identity: "work", sessionId: "session-9", headroom: true, routeByName: false }); + expect(route.seen[0]?.session).toEqual({ identity: "work", sessionId: "session-9", headroom: true }); expect(route.seen[0]?.headers).toEqual({ authorization: "Bearer tok" }); }); @@ -336,7 +336,7 @@ function routedWith(response: FakeServerResponse, observers: readonly ResponseOb const { deps: d, logs } = deps({ ok: true, route: recordingRoute() }, observers); const routed = createRoutedResponse(response as unknown as PipelineRequest["response"], { deps: d, - session: { identity: "work", sessionId: "s", provider: undefined, headroom: false, routeByName: false, projectId: undefined }, + session: { identity: "work", sessionId: "s", provider: undefined, headroom: false, projectId: undefined }, route: "test", request: { method: "POST", path: "/v1/messages", receivedAt: 0, viaHeadroom: false, thread: { lineage: undefined }, modelChoice: { value: undefined }, requestedModel: { value: undefined } }, servedPath: "/v1/messages", diff --git a/src/frontdoor/providerRoute.test.ts b/src/frontdoor/providerRoute.test.ts index 68364a97..a2e9c193 100644 --- a/src/frontdoor/providerRoute.test.ts +++ b/src/frontdoor/providerRoute.test.ts @@ -13,7 +13,7 @@ import type { UsageSnapshot } from "../usage/schema"; import { createProviderRouteResolver} from "./providerRoute"; import { serveRouted } from "./pipeline"; import { createFrontDoorServer, listenFrontDoor } from "./server"; -import { AUTH_HEADER, ROUTE_BY_NAME_HEADER, type RoutedRequest, type RoutedResponse } from "./route"; +import { AUTH_HEADER, type RoutedRequest, type RoutedResponse } from "./route"; const PROVIDERS_DIR = `${FAKE_HOME}/.agent-shim/providers`; @@ -59,7 +59,7 @@ function resolver(files: Record, directPort = OWN_PORT, models? } function request(url: string, body = "{}"): RoutedRequest { - return { method: "POST", url, headers: {}, body: Readable.from([Buffer.from(body)]), signal: new AbortController().signal, session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, routeByName: false, projectId: undefined } }; + return { method: "POST", url, headers: {}, body: Readable.from([Buffer.from(body)]), signal: new AbortController().signal, session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, projectId: undefined } }; } describe("createProviderRouteResolver", () => { @@ -130,10 +130,26 @@ describe("createProviderRouteResolver", () => { } }); + it("resolves a bare name only the models block defines on every session, and keeps a name the session's own provider serves itself", async () => { + const plain = (model: string): RoutedRequest => ({ + ...request("/v1/messages", JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: "hi" }] })), + session: { identity: undefined, sessionId: undefined, provider: "z", headroom: false, projectId: undefined }, + }); + // `fast` is only the block's to serve; `glm-5.3` z serves itself, so it stays z's own. + const models: ModelsConfig = { virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] }, "glm-5.3": { mode: "ordered", members: ["z/glm-5.3", "s/glm-5.3"] } } }; + const resolve = createProviderRouteResolver({ fs: fakeFs({ [`${PROVIDERS_DIR}/z.json`]: httpProvider, [`${PROVIDERS_DIR}/s.json`]: { displayName: "S", baseUrl: "https://api.synthetic.new/anthropic", credential: { sources: [{ literal: "s-token" }] } } }), providersDir: PROVIDERS_DIR, codexPorts: codexPorts(), directPort: () => OWN_PORT, env: { Z: "tok-from-z" }, credentials: fakeCredentials(), models: () => models, targets: () => ["z/glm-5.3", "s/glm-5.3"] }); + const fast = await resolve(plain("fast")); + if (!fast.ok) throw new Error(fast.message); + expect(fast.route.name).toBe("http:z"); + expect(fast.modelChoice).toEqual({ requested: "fast", providers: ["z"] }); + const own = await resolve(plain("glm-5.3")); + if (!own.ok) throw new Error(own.message); + expect(own.route.name).toBe("http:z"); + expect(own.modelChoice).toBeUndefined(); }); + 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 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, 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"); @@ -142,16 +158,12 @@ describe("createProviderRouteResolver", () => { // 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" }] })), - session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, routeByName: true, projectId: undefined }, + session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, projectId: undefined }, }); const resolve = resolver({ [`${PROVIDERS_DIR}/z.json`]: httpProvider }); @@ -180,7 +192,7 @@ describe("createProviderRouteResolver", () => { it("routes a name the models block defines, a virtual model's or a family's, from a provider-less (OAuth) session that asked to route by name", async () => { const byName = (model: string): RoutedRequest => ({ ...request("/v1/messages", JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: "hi" }] })), - session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, routeByName: true, projectId: undefined }, + session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, projectId: undefined }, }); const models: ModelsConfig = { virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }; const resolve = createProviderRouteResolver({ fs: fakeFs({ [`${PROVIDERS_DIR}/z.json`]: httpProvider }), providersDir: PROVIDERS_DIR, codexPorts: codexPorts(), directPort: () => OWN_PORT, env: { Z: "tok-from-z" }, credentials: fakeCredentials(), models: () => models, targets: () => ["z/glm-5.3"] }); @@ -206,7 +218,7 @@ describe("createProviderRouteResolver", () => { it("serves a provider session's bare /v1/ target through its provider's route, the request rewritten under the scoped path the route parses", async () => { // An antigravity provider whose credential never resolves: the route must get as far as its credential (a 401 naming the provider), which a bare unrewritten path never reaches because the route cannot parse a provider out of it. const resolve = resolver({ [`${PROVIDERS_DIR}/ag.json`]: { kind: "antigravity", displayName: "AG", credential: { sources: [{ env: "NOT_SET_ANYWHERE" }] }, antigravity: { mode: "cloud" } } }); - const sessionRequest = (url: string): RoutedRequest => ({ ...request(url, JSON.stringify({ model: "claude-haiku-5-5", messages: [{ role: "user", content: "Reply OK" }] })), session: { identity: undefined, sessionId: undefined, provider: "ag", headroom: false, routeByName: false, projectId: undefined } }); + const sessionRequest = (url: string): RoutedRequest => ({ ...request(url, JSON.stringify({ model: "claude-haiku-5-5", messages: [{ role: "user", content: "Reply OK" }] })), session: { identity: undefined, sessionId: undefined, provider: "ag", headroom: false, projectId: undefined } }); const resolution = await resolve(sessionRequest("/v1/messages?beta=true")); expect(resolution.ok).toBe(true); if (!resolution.ok) { @@ -739,7 +751,7 @@ describe("a router provider", () => { describe("an account model member", () => { const sessionRequest = (model: string): RoutedRequest => ({ ...request("/providers/z/v1/messages", JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: "hi" }] })), - session: { identity: undefined, sessionId: "s-1", provider: "z", headroom: false, routeByName: true, projectId: undefined }, + session: { identity: undefined, sessionId: "s-1", provider: "z", headroom: false, projectId: undefined }, }); it("is answered with a clear refusal naming the identity, and a move request is left for the session's supervisor", async () => { @@ -792,7 +804,7 @@ describe("routing by name from a provider session", () => { try { const response = await fetch(`${door.url}/providers/z/v1/messages`, { method: "POST", - headers: { "content-type": "application/json", [AUTH_HEADER]: "launch-token-for-tests", [ROUTE_BY_NAME_HEADER]: "1" }, + headers: { "content-type": "application/json", [AUTH_HEADER]: "launch-token-for-tests" }, body: JSON.stringify({ model: "m/glm-5.3", max_tokens: 16, messages: [{ role: "user", content: "hi" }] }), }); expect(response.status).toBe(HTTP_STATUS.ok); @@ -819,7 +831,7 @@ describe("a family and the capabilities it carries", () => { /** A routing session's request for `model`, its body optionally carrying an image block. */ const asked = (model: string, withImage = false): RoutedRequest => ({ ...request("/providers/z/v1/messages", JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: withImage ? [{ type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }] : "hi" }] })), - session: { identity: "work", sessionId: "s-1", provider: "z", headroom: false, routeByName: true, projectId: undefined }, + session: { identity: "work", sessionId: "s-1", provider: "z", headroom: false, projectId: undefined }, }); /** The provider and model name the resolution handed on, read from the replayed body. */ @@ -860,7 +872,7 @@ describe("a family and the capabilities it carries", () => { describe("a virtual model's choice on the resolution", () => { const sessionRequest = (model: string): RoutedRequest => ({ ...request("/providers/z/v1/messages", JSON.stringify({ model, max_tokens: 16, messages: [{ role: "user", content: "hi" }] })), - session: { identity: undefined, sessionId: "s-1", provider: "z", headroom: false, routeByName: true, projectId: undefined }, + session: { identity: undefined, sessionId: "s-1", provider: "z", headroom: false, projectId: undefined }, }); const files: Record = { [`${PROVIDERS_DIR}/z.json`]: httpProvider, diff --git a/src/frontdoor/providerRoute.ts b/src/frontdoor/providerRoute.ts index 9031ac76..98357ba0 100644 --- a/src/frontdoor/providerRoute.ts +++ b/src/frontdoor/providerRoute.ts @@ -80,7 +80,6 @@ function resolveRoutedModel( providerName: string | undefined, session: Readonly<{ readonly identity?: string; readonly sessionId?: string }>, deps: Readonly, - byName: boolean, memory: Readonly>, rotations: Readonly>, needs: RequestNeeds | undefined, @@ -95,8 +94,11 @@ function resolveRoutedModel( } const isFamily = model.startsWith(FAMILY_PREFIX); const virtual = isFamily ? undefined : models?.virtual?.[model]; - // 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)) { + // A bare name is the models block's to resolve only when the session's own provider does not serve that very name: the provider the session launched on wins for the names it serves (the launch chose it), and the block serves the names nothing else can. A name neither defines stays Claude Code's own. + if (virtual === undefined && !isFamily) { + return undefined; + } + if (providerName !== undefined && (deps.targets?.() ?? []).some((ref) => ref === `${providerName}/${model}` || withoutContextMarker(ref) === `${providerName}/${withoutContextMarker(model)}`)) { return undefined; } const declared = isFamily ? [model] : expandMembers(model, models); @@ -369,17 +371,14 @@ export function createProviderRouteResolver(deps: { visited.add(providerName); } let routes; - let isRouter; try { const definition = providerName === undefined ? undefined : loadProvider(deps.providersDir, providerName, deps.fs); routes = definition === undefined || !isHttpProvider(definition) ? undefined : definition.routes; - isRouter = definition !== undefined && isRouterProvider(definition); } catch { // An invalid provider file is the sync resolver's own refusal to report, not this layer's. 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 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. + // 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 everywhere, except a name the session's own provider serves itself, which stays that provider's. 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. @@ -407,7 +406,7 @@ 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, definesProvider }, isRouter || current.session.routeByName, routingMemory, rotations, needsOf(scan)); + const routed = scan.model === undefined ? undefined : resolveRoutedModel(scan.model, providerName, current.session, { ...deps, definesProvider }, 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. diff --git a/src/frontdoor/rcSessions.test.ts b/src/frontdoor/rcSessions.test.ts index cebbe776..f93313a8 100644 --- a/src/frontdoor/rcSessions.test.ts +++ b/src/frontdoor/rcSessions.test.ts @@ -378,7 +378,7 @@ describe("the RoutedResponse tee the door's resolver applies", () => { // A real IncomingMessage over an unconnected socket, fed by hand: the additive listeners must see exactly these bytes, and the route's own flow must be unchanged. body, signal: new AbortController().signal, - session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, routeByName: false, projectId: undefined }, + session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, projectId: undefined }, }; const served = observingRoutedRoute(route, tracker).serve(request, recordingResponse); body.push(JSON.stringify({ worker_epoch: 1, events: [{ payload: { type: "control_request", request_id: "req-tee-1", request: { subtype: "can_use_tool", tool_name: "Bash", input: { command: "pnpm test" } } } }] })); @@ -436,7 +436,7 @@ describe("the RoutedResponse tee the door's resolver applies", () => { // A real IncomingMessage over an unconnected socket: the stand-in route never reads the body, and constructing the genuine type keeps the request honest. body: new IncomingMessage(new Socket()), signal: new AbortController().signal, - session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, routeByName: false, projectId: undefined }, + session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, projectId: undefined }, }; await observingRoutedRoute(route, tracker).serve(request, recordingResponse); expect(writes).toEqual([Buffer.from(JSON.stringify({ session: { id: SESSION_ID } }), "utf8")]); diff --git a/src/frontdoor/route.test.ts b/src/frontdoor/route.test.ts index 07d1981c..4950a0bf 100644 --- a/src/frontdoor/route.test.ts +++ b/src/frontdoor/route.test.ts @@ -13,13 +13,13 @@ describe("identifyRequest", () => { authorization: "Bearer token", "user-agent": "claude/1", }); - expect(identified.session).toEqual({ identity: "work", sessionId: "session-1", provider: "z", headroom: true, routeByName: false, projectId: "/repo" }); + expect(identified.session).toEqual({ identity: "work", sessionId: "session-1", provider: "z", headroom: true, projectId: "/repo" }); expect(identified.forwardableHeaders).toEqual({ authorization: "Bearer token", "user-agent": "claude/1" }); }); it("reads internal header names case-insensitively, as the transport normalises them", () => { const identified = identifyRequest({ "X-Agent-Shim-Identity": "work", "X-AGENT-SHIM-SESSION": "session-2" }); - expect(identified.session).toEqual({ identity: "work", sessionId: "session-2", provider: undefined, headroom: false, routeByName: false, projectId: undefined }); + expect(identified.session).toEqual({ identity: "work", sessionId: "session-2", provider: undefined, headroom: false, projectId: undefined }); expect(identified.forwardableHeaders).toEqual({}); }); @@ -30,11 +30,11 @@ describe("identifyRequest", () => { it("treats a session with no injected headers as headroom-less and anonymous", () => { const identified = identifyRequest({ authorization: "Bearer token" }); - expect(identified.session).toEqual({ identity: undefined, sessionId: undefined, provider: undefined, headroom: false, routeByName: false, projectId: undefined }); + expect(identified.session).toEqual({ identity: undefined, sessionId: undefined, provider: undefined, headroom: false, projectId: undefined }); }); it("names exactly the internal headers, so a new one cannot be added without widening the strip", () => { - expect(INTERNAL_HEADER_NAMES).toEqual(["x-agent-shim-identity", "x-agent-shim-session", "x-agent-shim-headroom", "x-agent-shim-route-by-name", "x-agent-shim-auth", "x-agent-shim-provider", "x-agent-shim-hop", "x-agent-shim-hop-id", "x-headroom-project-id", "x-headroom-session-id", "x-headroom-base-url"]); + expect(INTERNAL_HEADER_NAMES).toEqual(["x-agent-shim-identity", "x-agent-shim-session", "x-agent-shim-headroom", "x-agent-shim-auth", "x-agent-shim-provider", "x-agent-shim-hop", "x-agent-shim-hop-id", "x-headroom-project-id", "x-headroom-session-id", "x-headroom-base-url"]); }); }); diff --git a/src/frontdoor/route.ts b/src/frontdoor/route.ts index 7f45a122..9ea67fc6 100644 --- a/src/frontdoor/route.ts +++ b/src/frontdoor/route.ts @@ -13,8 +13,7 @@ export const INTERNAL_HEADER_NAMES = [ "x-agent-shim-identity", "x-agent-shim-session", "x-agent-shim-headroom", - "x-agent-shim-route-by-name", - "x-agent-shim-auth", + "x-agent-shim-auth", "x-agent-shim-provider", "x-agent-shim-hop", "x-agent-shim-hop-id", @@ -35,9 +34,6 @@ export const PROVIDER_HEADER = "x-agent-shim-provider"; /** Set to `1` when the launch resolved headroom on, so the front door knows this session's requests must take the headroom hop; stripped at the door. */ export const HEADROOM_FLAG_HEADER = "x-agent-shim-headroom"; -/** The launcher-injected header that says this session routes provider-qualified model names through the models block, whatever provider it launched on. */ -export const ROUTE_BY_NAME_HEADER = "x-agent-shim-route-by-name"; - /** * Carries the project identity headroom scopes its memory state to (the git repository root of the launch directory). Also injected by the launcher and stripped at the door: the headroom hop re-sets it on its own request, so it reaches headroom and nowhere else. */ @@ -80,7 +76,6 @@ export interface SessionIdentity { /** Whether this launch resolved headroom on, so its requests must pass through the headroom hop before their route. */ readonly headroom: boolean; /** Whether this launch routes provider-qualified model names through the models block, whatever provider it selected. */ - readonly routeByName: boolean; /** The project identity headroom scopes memory to, re-set on the headroom hop and sent nowhere else. */ readonly projectId: string | undefined; } @@ -132,7 +127,6 @@ export function identifyRequest(headers: Readonly): Identif sessionId: internalHeaderValue(headers, SESSION_HEADER), provider: internalHeaderValue(headers, PROVIDER_HEADER), headroom: internalHeaderValue(headers, HEADROOM_FLAG_HEADER) === "1", - routeByName: internalHeaderValue(headers, ROUTE_BY_NAME_HEADER) === "1", projectId: internalHeaderValue(headers, PROJECT_ID_HEADER), }, forwardableHeaders: forwardable, diff --git a/src/frontdoor/server.test.ts b/src/frontdoor/server.test.ts index 4ada63a6..26002984 100644 --- a/src/frontdoor/server.test.ts +++ b/src/frontdoor/server.test.ts @@ -149,7 +149,7 @@ describe("createFrontDoorServer", () => { expect(echoed).not.toContain(IDENTITY_HEADER); expect(echoed).not.toContain(SESSION_HEADER); expect(echoed).not.toContain(HEADROOM_FLAG_HEADER); - expect(seen?.session).toEqual({ identity: "work", sessionId: "session-1", headroom: true, routeByName: false, projectId: undefined }); + expect(seen?.session).toEqual({ identity: "work", sessionId: "session-1", headroom: true, projectId: undefined }); } finally { await door.close(); } diff --git a/src/frontdoor/state.ts b/src/frontdoor/state.ts index 2dcf1e63..ade0fab1 100644 --- a/src/frontdoor/state.ts +++ b/src/frontdoor/state.ts @@ -11,11 +11,11 @@ import type { HeadroomFs } from "../headroom/state"; * * Protocol 3: the door attaches an http provider's own credential at its route, and this release's launcher no longer hands the child a provider credential to present. A protocol 2 door forwards whatever the child presented, so a protocol 3 launcher's provider sessions would arrive with no credential at all and fail at the provider; the door is replaced rather than joined. * - * Protocol 4: the door resolves a request's model name through the models block for a session that routes by name, and this release's launcher states that choice in a session header. A protocol 3 door ignores the header and sends every request to the session's own provider under the name it was given, so a protocol 4 launcher's route-by-name sessions would reach the wrong provider with a model name it does not serve; the door is replaced rather than joined. + * Protocol 5: the door resolves a request's model name through the models block for every session, with no launch opting in. A protocol 4 door resolves only the sessions whose launch stated the choice in a session header this release no longer sends, so its sessions would reach their own provider with a name it does not serve; the door is replaced rather than joined. * * The rule that makes a bump safe: a door accepts every lower protocol's launches (protocol 1 accepts the former `x-claude-use-*` header names), and a launcher replaces a door whose protocol is lower than its own, never one that is higher. Replacing is safe because restarts are what the door is designed for: the capability registry is on disk and the listeners keep their sticky ports, so launches already running reach the replacement on the addresses they froze at launch. */ -export const FRONT_DOOR_PROTOCOL = 4; +export const FRONT_DOOR_PROTOCOL = 5; /** * The record held in one front-door session-registry file: the launcher's pid plus the per-launch capability token its child presents on every request, and the supervisor of the door the launch was sent to. `sessionId` is the id the launch's requests carry in their session header, and `models` the models block this launch resolved when its directory, profile or committed file added definitions of its own to the global block (absent otherwise, so the door follows the global block live). `supervisorPid` is absent on a record written before it existed and on a door's own transparent-surface record; such a launch cannot be attributed to a door, so a superseded door treats it as its own. diff --git a/src/launcher.test.ts b/src/launcher.test.ts index d9c5abf7..fc50f0d3 100644 --- a/src/launcher.test.ts +++ b/src/launcher.test.ts @@ -393,7 +393,7 @@ describe("runLauncher headroom routing", () => { proc: fakeProc({}, ["@work"]), log, resolveClaudeBinary: () => discovered, - farm: fakeFarm(fs, { launch: { headroom: true, routeByName: false } }), + farm: fakeFarm(fs, { launch: { headroom: true } }), frontdoor: fakeFrontDoorPort(), headroom, }); @@ -415,7 +415,7 @@ describe("runLauncher headroom routing", () => { proc: fakeProc({ CLAUDE_CONFIG_DIR: "/somewhere/explicit" }, ["--print"]), log: fakeLog(), resolveClaudeBinary: () => discovered, - farm: fakeFarm(fs, { launch: { headroom: true, routeByName: false } }), + farm: fakeFarm(fs, { launch: { headroom: true } }), frontdoor: fakeFrontDoorPort(), headroom, }); @@ -526,7 +526,7 @@ describe("runLauncher headroom routing", () => { expect(log.errors[0]).toContain("provider z has no usable credential"); }); - it("launches a route-by-name session whose routed providers all resolve, stripping the route flag into the session headers", () => { + it("launches with a models block whose providers all resolve, injecting no routing header: every session resolves names and none opts in", () => { const spawn = fakeSpawn(); runAndCaptureExit({ @@ -542,7 +542,7 @@ describe("runLauncher headroom routing", () => { }, }), spawn, - proc: fakeProc({ Z_API_TOKEN: "tok-z" }, ["--route-by-name"]), + proc: fakeProc({ Z_API_TOKEN: "tok-z" }, []), log: fakeLog(), resolveClaudeBinary: () => discovered, frontdoor: fakeFrontDoorPort(), @@ -550,7 +550,7 @@ describe("runLauncher headroom routing", () => { }); const env = spawnedEnv(spawn); - expect(injectedSessionHeaders(env)).toContain("x-agent-shim-route-by-name: 1"); + expect(injectedSessionHeaders(env).some((line) => line.startsWith("x-agent-shim-route-by-name"))).toBe(false); }); it("refuses loudly when headroom resolved on but no front-door port was wired", () => { diff --git a/src/launcher.ts b/src/launcher.ts index c44fafe8..38b3d0b9 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -482,7 +482,6 @@ function decideLaunch(params: PrepareLaunchParams, readOnly: boolean): DecidedLa ...(parsedArgv.trackUsage === undefined ? {} : { trackUsage: parsedArgv.trackUsage }), ...(parsedArgv.pause === undefined ? {} : { pause: parsedArgv.pause }), ...(parsedArgv.explain === undefined ? {} : { explain: parsedArgv.explain }), - ...(parsedArgv.routeByName === undefined ? {} : { routeByName: parsedArgv.routeByName }), ...(parsedArgv.delay === undefined ? {} : { delay: parsedArgv.delay }), }, }); @@ -497,7 +496,7 @@ function decideLaunch(params: PrepareLaunchParams, readOnly: boolean): DecidedLa // `/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; @@ -510,7 +509,7 @@ function decideLaunch(params: PrepareLaunchParams, readOnly: boolean): DecidedLa return []; } }); - const pickerSettings = modelPickerSettingsFromRows(routedModelRows(models ?? {}, listedProviders, routesByName), selectedProvider !== undefined); + const pickerSettings = modelPickerSettingsFromRows(routedModelRows(models ?? {}, listedProviders), 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"); @@ -637,9 +636,9 @@ export function prepareLaunch(params: PrepareLaunchParams): LaunchPlan { rows.push(pinnedVersionRow(decided.pinnedVersion.version, decided.pinnedVersion.source)); } - // A router or routeByName session sends provider-qualified model names the door resolves through the models block, so every provider a definition names is about to carry this session's requests: its credential is resolved now, so a missing one fails the launch naming the provider instead of surfacing as a bare 502 on the first request that names it. A family member is checked through every provider that offers a target of the family, and an account member is the door's to resolve (it relaunches rather than dialling out). A read-only resolution never reaches this code, so it still resolves no credential. - const routesByName = resolvedFlags.routeByName || (resolvedProvider !== undefined && isRouterProvider(resolvedProvider.definition)); - if (routesByName) { + // Every session resolves model names through the models block when one exists, so every provider a definition names can carry this session's requests: its credential is resolved now, so a missing one fails the launch naming the provider instead of surfacing as a bare 502 on the first request that names it. A family member is checked through every provider that offers a target of the family, and an account member is the door's to resolve (it relaunches rather than dialling out). A read-only resolution never reaches this code, so it still resolves no credential. + const namesResolved = decided.models !== undefined; + if (namesResolved) { const check = resolveRoutedProviderCredentials({ paths, port: params.fs, env, ...(params.credentials === undefined ? {} : { credentials: params.credentials }), models: decided.models, targets: () => targetRefs(paths) }); if (!check.ok) { log.error(check.message); @@ -685,7 +684,7 @@ export function prepareLaunch(params: PrepareLaunchParams): LaunchPlan { ...(headroom === undefined ? {} : { headroom }), sessionId, remoteControl: resolvedFlags.remoteControl, - routeByName: routesByName, + ...(decided.models?.tiers === undefined ? {} : { tierModels: decided.models.tiers }), }); @@ -720,7 +719,7 @@ export interface LaunchResolution { /** The credential the launch would authenticate with, summarised by target and source kinds. Absent when none applies. */ readonly credential?: { readonly subject: "provider" | "identity"; readonly name: string; readonly summary: CredentialSummary }; /** Which daemons the launch would route through, and whether it routes provider-qualified model names through the models block. */ - readonly routing: { readonly frontDoor: boolean; readonly headroom: boolean; readonly routeByName: boolean }; + readonly routing: { readonly frontDoor: boolean; readonly headroom: boolean }; /** The names of the environment variables the launch would set or unset, sorted. Values are never reported: the launcher's environment carries tokens and per-launch capabilities that mean nothing outside it. */ readonly environment: readonly string[]; } @@ -745,7 +744,7 @@ export function resolveLaunch(params: PrepareLaunchParams): LaunchResolution { ...(decided.flags.headroom ? { headroom: { socketPath: "", projectId: "" } } : {}), sessionId: "", remoteControl: decided.flags.remoteControl, - routeByName: decided.flags.routeByName, + }); const credentialNames = decided.identityCredentialBlock === undefined ? [] : Object.keys(credentialVariables(decided.identityCredentialBlock.target ?? "bearer", "")); const environment = [...new Set([...Object.keys(built).filter((name) => built[name] !== env[name]), ...credentialNames])].sort(); @@ -764,7 +763,7 @@ export function resolveLaunch(params: PrepareLaunchParams): LaunchResolution { bin: decided.bin, args: decided.args, ...(credential === undefined ? {} : { credential }), - routing: { frontDoor, headroom: decided.flags.headroom, routeByName: decided.flags.routeByName }, + routing: { frontDoor, headroom: decided.flags.headroom }, environment, }; } diff --git a/src/launcher/argv.ts b/src/launcher/argv.ts index 1ae42a87..1de1a66e 100644 --- a/src/launcher/argv.ts +++ b/src/launcher/argv.ts @@ -20,8 +20,6 @@ export interface ParsedLauncherArgv { readonly pause?: boolean; /** The last `--explain`/`--no-explain` occurrence, when either was given: whether the launcher's report lists every pool member the pick ranked. */ readonly explain?: boolean; - /** The last `--route-by-name`/`--no-route-by-name` occurrence, when either was given: whether a provider session routes qualified model names through the models block. */ - readonly routeByName?: boolean; /** The last `--wait`/`--no-wait` occurrence, when either was given: with a pool selected and every member refused, sleep until the earliest returns instead of refusing the launch. */ readonly wait?: boolean; /** True when `--native` was given: run the real `claude` with nothing from agent-shim applied. It has no `--no-` form and cannot be combined with any other launch flag. */ @@ -60,7 +58,6 @@ const BOOLEAN_FLAGS = [ { flag: "--wait", key: "wait" }, { flag: "--pause", key: "pause" }, { flag: "--explain", key: "explain" }, - { flag: "--route-by-name", key: "routeByName" }, ] as const; type BooleanFlagKey = (typeof BOOLEAN_FLAGS)[number]["key"]; diff --git a/src/launcher/flags.test.ts b/src/launcher/flags.test.ts index 9c12705b..055511b3 100644 --- a/src/launcher/flags.test.ts +++ b/src/launcher/flags.test.ts @@ -5,14 +5,14 @@ import { buildArgv, buildEnv, buildFlagArgs, resolveLaunchFlags, type ResolvedPr describe("resolveLaunchFlags", () => { it("defaults skipPermissions, remoteControl and headroom to off and trackUsage to on when nothing sets them", () => { - expect(resolveLaunchFlags({ env: {} })).toEqual({ skipPermissions: false, remoteControl: false, headroom: false, routeByName: false, trackUsage: true, pause: false, explain: false, delayMs: 0 }); + expect(resolveLaunchFlags({ env: {} })).toEqual({ skipPermissions: false, remoteControl: false, headroom: false, trackUsage: true, pause: false, explain: false, delayMs: 0 }); }); it("turns skipPermissions on via the AGENT_SHIM_SKIP_PERMISSIONS=1 escape hatch", () => { expect(resolveLaunchFlags({ env: { AGENT_SHIM_SKIP_PERMISSIONS: "1" } })).toEqual({ skipPermissions: true, remoteControl: false, - headroom: false, routeByName: false, + headroom: false, trackUsage: true, pause: false, explain: false, @@ -24,7 +24,7 @@ describe("resolveLaunchFlags", () => { expect(resolveLaunchFlags({ env: { AGENT_SHIM_REMOTE_CONTROL: "1" } })).toEqual({ skipPermissions: false, remoteControl: true, - headroom: false, routeByName: false, + headroom: false, trackUsage: true, pause: false, explain: false, @@ -35,14 +35,14 @@ describe("resolveLaunchFlags", () => { it("treats an empty env value as unset", () => { expect( resolveLaunchFlags({ env: { AGENT_SHIM_SKIP_PERMISSIONS: "", AGENT_SHIM_REMOTE_CONTROL: "0" } }), - ).toEqual({ skipPermissions: false, remoteControl: false, headroom: false, routeByName: false, trackUsage: true, pause: false, explain: false, delayMs: 0 }); + ).toEqual({ skipPermissions: false, remoteControl: false, headroom: false, trackUsage: true, pause: false, explain: false, delayMs: 0 }); }); it("honours a cascade value once one is supplied, independent of the env escape hatch", () => { expect(resolveLaunchFlags({ env: {}, cascade: { skipPermissions: true, remoteControl: true } })).toEqual({ skipPermissions: true, remoteControl: true, - headroom: false, routeByName: false, + headroom: false, trackUsage: true, pause: false, explain: false, @@ -53,7 +53,7 @@ describe("resolveLaunchFlags", () => { it("combines settings from different sources independently, each setting decided on its own", () => { expect( resolveLaunchFlags({ env: { AGENT_SHIM_REMOTE_CONTROL: "1" }, cascade: { skipPermissions: true } }), - ).toEqual({ skipPermissions: true, remoteControl: true, headroom: false, routeByName: false, trackUsage: true, pause: false, explain: false, delayMs: 0 }); + ).toEqual({ skipPermissions: true, remoteControl: true, headroom: false, trackUsage: true, pause: false, explain: false, delayMs: 0 }); }); it.each(["skipPermissions", "remoteControl", "headroom", "trackUsage"] as const)( @@ -83,16 +83,16 @@ describe("resolveLaunchFlags", () => { }); it("keeps the env escape hatch over the cascade for headroom when no flag was given", () => { - expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "1" }, cascade: { headroom: false, routeByName: false } }).headroom).toBe(true); - expect(resolveLaunchFlags({ env: {}, cascade: { headroom: true, routeByName: false } }).headroom).toBe(true); - expect(resolveLaunchFlags({ env: {}, cascade: { headroom: false, routeByName: false } }).headroom).toBe(false); + expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "1" }, cascade: { headroom: false } }).headroom).toBe(true); + expect(resolveLaunchFlags({ env: {}, cascade: { headroom: true } }).headroom).toBe(true); + expect(resolveLaunchFlags({ env: {}, cascade: { headroom: false } }).headroom).toBe(false); }); it("turns headroom on via the AGENT_SHIM_HEADROOM=1 escape hatch", () => { expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "1" } })).toEqual({ skipPermissions: false, remoteControl: false, - headroom: true, routeByName: false, + headroom: true, trackUsage: true, pause: false, explain: false, @@ -101,23 +101,23 @@ describe("resolveLaunchFlags", () => { }); it("defaults trackUsage to on, so every launch engages the front door, independent of headroom", () => { - expect(resolveLaunchFlags({ env: {} })).toMatchObject({ trackUsage: true, headroom: false, routeByName: false }); - expect(resolveLaunchFlags({ env: {}, cascade: { headroom: true, routeByName: false } })).toMatchObject({ trackUsage: true, headroom: true, routeByName: false }); - expect(resolveLaunchFlags({ env: {}, cascade: { headroom: false, routeByName: false } })).toMatchObject({ trackUsage: true, headroom: false, routeByName: false }); + expect(resolveLaunchFlags({ env: {} })).toMatchObject({ trackUsage: true, headroom: false }); + expect(resolveLaunchFlags({ env: {}, cascade: { headroom: true } })).toMatchObject({ trackUsage: true, headroom: true }); + expect(resolveLaunchFlags({ env: {}, cascade: { headroom: false } })).toMatchObject({ trackUsage: true, headroom: false }); }); it("turns trackUsage off through a flag, its env variable or the cascade, each independent of headroom", () => { - expect(resolveLaunchFlags({ env: {}, flags: { trackUsage: false } })).toMatchObject({ trackUsage: false, headroom: false, routeByName: false }); - expect(resolveLaunchFlags({ env: { AGENT_SHIM_TRACK_USAGE: "0" } })).toMatchObject({ trackUsage: false, headroom: false, routeByName: false }); - expect(resolveLaunchFlags({ env: {}, cascade: { trackUsage: false } })).toMatchObject({ trackUsage: false, headroom: false, routeByName: false }); - expect(resolveLaunchFlags({ env: {}, cascade: { trackUsage: false, headroom: true, routeByName: false } })).toMatchObject({ trackUsage: false, headroom: true, routeByName: false }); - expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "1", AGENT_SHIM_TRACK_USAGE: "0" } })).toMatchObject({ trackUsage: false, headroom: true, routeByName: false }); + expect(resolveLaunchFlags({ env: {}, flags: { trackUsage: false } })).toMatchObject({ trackUsage: false, headroom: false }); + expect(resolveLaunchFlags({ env: { AGENT_SHIM_TRACK_USAGE: "0" } })).toMatchObject({ trackUsage: false, headroom: false }); + expect(resolveLaunchFlags({ env: {}, cascade: { trackUsage: false } })).toMatchObject({ trackUsage: false, headroom: false }); + expect(resolveLaunchFlags({ env: {}, cascade: { trackUsage: false, headroom: true } })).toMatchObject({ trackUsage: false, headroom: true }); + expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "1", AGENT_SHIM_TRACK_USAGE: "0" } })).toMatchObject({ trackUsage: false, headroom: true }); }); it("resolves headroom from a cascade value and ORs it with the escape hatch", () => { - expect(resolveLaunchFlags({ env: {}, cascade: { headroom: true, routeByName: false } }).headroom).toBe(true); - expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "0" }, cascade: { headroom: false, routeByName: false } }).headroom).toBe(false); - expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "1" }, cascade: { headroom: false, routeByName: false } }).headroom).toBe(true); + expect(resolveLaunchFlags({ env: {}, cascade: { headroom: true } }).headroom).toBe(true); + expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "0" }, cascade: { headroom: false } }).headroom).toBe(false); + expect(resolveLaunchFlags({ env: { AGENT_SHIM_HEADROOM: "1" }, cascade: { headroom: false } }).headroom).toBe(true); }); }); @@ -212,7 +212,7 @@ describe("buildEnv", () => { const baseEnv = { PATH: "/usr/bin", HOME: "/home/testuser" }; it("points the tier model settings at the models block's tier pointers for a session that routes by name, and leaves a tier without a pointer on the provider's own table", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: true, tierModels: { opus: "fast", haiku: "family:glm-5-1" }, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, tierModels: { opus: "fast", haiku: "family:glm-5-1" }, baseEnv: { ...baseEnv, ANTHROPIC_DEFAULT_SONNET_MODEL: "kept", ANTHROPIC_DEFAULT_OPUS_MODEL: "replaced" }, configDirEscapeHatch: true, identitiesDir: "/home/testuser/.agent-shim/identities", @@ -222,17 +222,17 @@ describe("buildEnv", () => { expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("kept"); }); - it("ignores the tier pointers for a session that does not route by name, since only the door's name resolution can serve a ref", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, tierModels: { opus: "fast" }, + it("applies the tier pointers whatever the session, since the door resolves names on every session", () => { + const env = buildEnv({ sessionId: "session-test", remoteControl: false, tierModels: { opus: "fast" }, baseEnv: { ...baseEnv, ANTHROPIC_DEFAULT_OPUS_MODEL: "kept" }, configDirEscapeHatch: true, identitiesDir: "/home/testuser/.agent-shim/identities", }); - expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("kept"); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("fast"); }); it("leaves the environment unchanged when the CLAUDE_CONFIG_DIR escape hatch applied", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv: { ...baseEnv, CLAUDE_CONFIG_DIR: "/somewhere/else" }, configDirEscapeHatch: true, resolvedIdentityName: "work", @@ -242,7 +242,7 @@ describe("buildEnv", () => { }); it("leaves the environment unchanged when no identity was resolved at all", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv, configDirEscapeHatch: false, identitiesDir: "/home/testuser/.agent-shim/identities", @@ -252,7 +252,7 @@ describe("buildEnv", () => { }); it("sets CLAUDE_CONFIG_DIR to the resolved identity's own directory otherwise", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", @@ -263,7 +263,7 @@ describe("buildEnv", () => { }); it("never strips CLAUDE_EXTRA_FLAGS from the child environment", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv: { ...baseEnv, CLAUDE_EXTRA_FLAGS: "--continue continue" }, configDirEscapeHatch: false, resolvedIdentityName: "work", @@ -293,7 +293,7 @@ describe("buildEnv", () => { } it("applies a resolved provider on top of the identity's CLAUDE_CONFIG_DIR", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", identitiesDir, provider: resolvedProvider() }); + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", identitiesDir, provider: resolvedProvider() }); expect(env.CLAUDE_CONFIG_DIR).toBe("/home/testuser/.agent-shim/identities/work"); // The child keeps the OAuth shape: no base URL (it believes it talks to api.anthropic.com through the door's CONNECT surface), and what it presents is a placeholder, never the provider's own token (the door attaches that at its route). expect(env.ANTHROPIC_BASE_URL).toBeUndefined(); @@ -302,25 +302,25 @@ describe("buildEnv", () => { }); it("carries no credential variable for a provider session with Remote Control on, which needs Claude Code's OAuth mode", () => { - const env = buildEnv({ sessionId: "session-test", baseEnv: { ...baseEnv, ANTHROPIC_AUTH_TOKEN: "tok-ambient" }, configDirEscapeHatch: false, resolvedIdentityName: "work", identitiesDir, provider: resolvedProvider(), remoteControl: true, routeByName: false }); + const env = buildEnv({ sessionId: "session-test", baseEnv: { ...baseEnv, ANTHROPIC_AUTH_TOKEN: "tok-ambient" }, configDirEscapeHatch: false, resolvedIdentityName: "work", identitiesDir, provider: resolvedProvider(), remoteControl: true }); expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined(); }); it("never hands a provider session the provider's own token as what it presents", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", identitiesDir, provider: resolvedProvider({ token: "tok-the-provider-secret" }) }); + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", identitiesDir, provider: resolvedProvider({ token: "tok-the-provider-secret" }) }); expect(JSON.stringify(env)).not.toContain("tok-the-provider-secret"); }); it("applies a resolved provider even when no identity was resolved, since it selects an endpoint, not a login", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, baseEnv, configDirEscapeHatch: false, identitiesDir, provider: resolvedProvider() }); + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv, configDirEscapeHatch: false, identitiesDir, provider: resolvedProvider() }); expect(env.CLAUDE_CONFIG_DIR).toBeUndefined(); expect(env.ANTHROPIC_AUTH_TOKEN).toBe("agent-shim-provider-session"); }); it("replaces every ambient credential variable for a provider session, so the child presents the placeholder and no other credential", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv: { ...baseEnv, ANTHROPIC_AUTH_TOKEN: "tok-ambient", ANTHROPIC_API_KEY: "sk-ambient", CLAUDE_CODE_OAUTH_TOKEN: "oauth-ambient" }, configDirEscapeHatch: false, identitiesDir, @@ -332,7 +332,7 @@ describe("buildEnv", () => { }); it("gives an apiKey-target provider's session the same placeholder, in the bearer variable: the door attaches the key whatever the target", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv: { ...baseEnv, ANTHROPIC_AUTH_TOKEN: "sk-ambient-bearer", ANTHROPIC_API_KEY: "sk-ambient-key" }, configDirEscapeHatch: false, identitiesDir, @@ -344,7 +344,7 @@ describe("buildEnv", () => { }); it("exports an identity's own credential as its target when no provider was selected", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv: { ...baseEnv, ANTHROPIC_API_KEY: "sk-ambient" }, configDirEscapeHatch: false, resolvedIdentityName: "work", @@ -357,7 +357,7 @@ describe("buildEnv", () => { }); it("never applies an identity's credential alongside a provider's, whose credential authenticates against its endpoint", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", @@ -370,7 +370,7 @@ describe("buildEnv", () => { }); it("lands every entry of the provider's own env verbatim in the child environment", () => { - const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, + const env = buildEnv({ sessionId: "session-test", remoteControl: false, baseEnv, configDirEscapeHatch: false, identitiesDir, @@ -384,7 +384,7 @@ describe("buildEnv", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, - routeByName: false, + baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", @@ -406,7 +406,7 @@ describe("buildEnv", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, - routeByName: false, + baseEnv: { ...baseEnv, HTTPS_PROXY: "http://agent-shim:parent-token@127.0.0.1:4200", NO_PROXY: "corp.example", no_proxy: "corp.example" }, configDirEscapeHatch: false, resolvedIdentityName: "work", @@ -423,7 +423,7 @@ describe("buildEnv", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, - routeByName: false, + baseEnv: { ...baseEnv, NO_PROXY: "localhost" }, configDirEscapeHatch: false, identitiesDir, @@ -436,7 +436,7 @@ describe("buildEnv", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, - routeByName: false, + baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", @@ -451,7 +451,7 @@ describe("buildEnv", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, - routeByName: false, + baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", @@ -470,7 +470,7 @@ describe("buildEnv", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, - routeByName: false, + baseEnv: { ...baseEnv, ANTHROPIC_BASE_URL: "https://custom-gateway.example" }, configDirEscapeHatch: false, identitiesDir: "/home/testuser/.agent-shim/identities", @@ -484,7 +484,7 @@ describe("buildEnv", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, - routeByName: false, + baseEnv: { ...baseEnv, ANTHROPIC_CUSTOM_HEADERS: "x-from-parent: yes" }, configDirEscapeHatch: false, identitiesDir: "/home/testuser/.agent-shim/identities", @@ -498,7 +498,7 @@ describe("buildEnv", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, - routeByName: false, + baseEnv, configDirEscapeHatch: false, resolvedIdentityName: "work", diff --git a/src/launcher/flags.ts b/src/launcher/flags.ts index 8d40025c..424d2f55 100644 --- a/src/launcher/flags.ts +++ b/src/launcher/flags.ts @@ -7,7 +7,7 @@ import { CLAUDE_TIERS, type ClaudeTier, type LaunchFlags, type Provider } from " import { credentialVariables, type ResolvedCredential } from "../credential"; import { mergeAnthropicCustomHeaders } from "../headroom/headers"; import { connectProxyUrl } from "../frontdoor/capability"; -import { AUTH_HEADER, HEADROOM_FLAG_HEADER, IDENTITY_HEADER, PROJECT_ID_HEADER, PROVIDER_HEADER, ROUTE_BY_NAME_HEADER, SESSION_HEADER } from "../frontdoor/route"; +import { AUTH_HEADER, HEADROOM_FLAG_HEADER, IDENTITY_HEADER, PROJECT_ID_HEADER, PROVIDER_HEADER, SESSION_HEADER } from "../frontdoor/route"; import type { FrontDoorUp, HeadroomUp } from "./ports"; /** The fully resolved launch flags for one launch. */ @@ -22,7 +22,6 @@ export interface ResolvedLaunchFlags { /** Whether the launch's report lists every pool member the pick ranked and why, and not only the one picked. */ readonly explain: boolean; /** Whether a provider session routes provider-qualified model names through the models block to the provider that serves them. */ - readonly routeByName: boolean; /** How long the launcher waits after its own output before it starts claude, in milliseconds; zero for no wait. */ readonly delayMs: number; } @@ -35,7 +34,6 @@ interface LaunchFlagOverrides { readonly trackUsage?: boolean; readonly pause?: boolean; readonly explain?: boolean; - readonly routeByName?: boolean; /** `--delay`, as typed. */ readonly delay?: string; } @@ -77,7 +75,6 @@ export function resolveLaunchFlags(params: ResolveLaunchFlagsParams): ResolvedLa true, pause: params.flags?.pause ?? parseEnvBool("AGENT_SHIM_PAUSE", params.env.AGENT_SHIM_PAUSE) ?? params.cascade?.pause ?? false, explain: params.flags?.explain ?? parseEnvBool("AGENT_SHIM_EXPLAIN", params.env.AGENT_SHIM_EXPLAIN) ?? params.cascade?.explain ?? false, - routeByName: params.flags?.routeByName ?? parseEnvBool("AGENT_SHIM_ROUTE_BY_NAME", params.env.AGENT_SHIM_ROUTE_BY_NAME) ?? params.cascade?.routeByName ?? false, delayMs: resolveDelayMs(params), }; } @@ -175,7 +172,6 @@ export interface BuildEnvParams { /** Whether Remote Control is on for this launch. It needs Claude Code in OAuth mode, so a provider launch with it on carries no credential variable; one with it off carries a placeholder, which lets the session run on an identity with no usable login. */ readonly remoteControl: boolean; /** Whether the session routes provider-qualified model names through the models block: rides the session headers so the door's routing layer sees it. */ - readonly routeByName: boolean; /** The models block's tier pointers: each names the model ref the Claude tier stands for. They take effect only for a session that routes by name, since only the door's name resolution can serve a ref (`fast`, `z/glm-5.3`, `family:glm-5-1`) a tier's model setting would otherwise send to a provider that does not know it. */ readonly tierModels?: Readonly>>; } @@ -212,13 +208,11 @@ export function buildEnv(params: BuildEnvParams): Record { 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("lists the bare virtual and family rows beside the qualified ones, which every session serves", () => { + 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" }), []]]); + expect(rows.map((row) => row.model)).toEqual(["fast", "family:glm-5-1", "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", () => { diff --git a/src/launcher/modelPicker.ts b/src/launcher/modelPicker.ts index 4f72dcaf..213aeaed 100644 --- a/src/launcher/modelPicker.ts +++ b/src/launcher/modelPicker.ts @@ -155,7 +155,7 @@ 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 CatalogueModel[]])[], bare = true): readonly ModelPickerRow[] { +export function routedModelRows(models: ModelsConfig, providers: readonly (readonly [string, Provider, readonly CatalogueModel[]])[]): 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)); @@ -163,18 +163,17 @@ export function routedModelRows(models: ModelsConfig, providers: readonly (reado 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; - // 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]) => { + const virtual = 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 = bare ? sharedFamilies(families).flatMap((family) => { + }); + const familyRows = 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/launcher/native.ts b/src/launcher/native.ts index 7eef10b9..b93d429d 100644 --- a/src/launcher/native.ts +++ b/src/launcher/native.ts @@ -22,7 +22,6 @@ function otherLaunchFlags(parsed: ParsedLauncherArgv): string[] { ...(parsed.delay === undefined ? [] : ["--delay"]), ...(parsed.pause === undefined ? [] : ["--[no-]pause"]), ...(parsed.explain === undefined ? [] : ["--[no-]explain"]), - ...(parsed.routeByName === undefined ? [] : ["--[no-]route-by-name"]), ...(parsed.headroom === undefined ? [] : ["--[no-]headroom"]), ...(parsed.trackUsage === undefined ? [] : ["--[no-]track-usage"]), ...(parsed.skipPermissions === undefined ? [] : ["--[no-]skip-permissions"]), diff --git a/src/launcherResolve.test.ts b/src/launcherResolve.test.ts index fdff99cd..d643370b 100644 --- a/src/launcherResolve.test.ts +++ b/src/launcherResolve.test.ts @@ -59,10 +59,10 @@ describe("resolveLaunch", () => { const prepared = prepareLaunch({ ...ports({ argv: ["@pool:main", "--headroom", "--print"] }).params, headroom: { ensure: () => ({ socketPath: "/s", projectId: "p" }), release: () => undefined } }); expect(resolved.decision).toEqual(prepared.decision); expect(resolved.decision).toMatchObject({ identity: "work", identitySource: "argv", pool: { name: "main" } }); - expect(resolved.flags).toMatchObject({ headroom: true, routeByName: false }); + expect(resolved.flags).toMatchObject({ headroom: true }); expect(resolved.args).toEqual(prepared.args); expect(resolved.bin).toBe(discovered.path); - expect(resolved.routing).toEqual({ frontDoor: true, headroom: true, routeByName: false }); + expect(resolved.routing).toEqual({ frontDoor: true, headroom: true }); }); it("writes nothing: no farm resync, no recorded pool pick", () => { diff --git a/src/program.ts b/src/program.ts index 1d40a7d1..c2f423c9 100644 --- a/src/program.ts +++ b/src/program.ts @@ -61,12 +61,11 @@ Launching: --delay wait this long (5s, 500ms) after the launcher's own output before starting claude --[no-]pause wait for a key press after the launcher's own output before starting claude --[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 --native run the real claude with nothing from agent-shim applied (no other launch flag allowed) Environment: AGENT_SHIM_IDENTITY, AGENT_SHIM_CONFIG_PROFILE, AGENT_SHIM_SKIP_PERMISSIONS, - AGENT_SHIM_REMOTE_CONTROL, AGENT_SHIM_HEADROOM, AGENT_SHIM_TRACK_USAGE, AGENT_SHIM_DELAY, AGENT_SHIM_PAUSE, AGENT_SHIM_EXPLAIN, AGENT_SHIM_ROUTE_BY_NAME, AGENT_SHIM_ALLOW_AMBIENT_CREDENTIAL, + AGENT_SHIM_REMOTE_CONTROL, AGENT_SHIM_HEADROOM, AGENT_SHIM_TRACK_USAGE, AGENT_SHIM_DELAY, AGENT_SHIM_PAUSE, AGENT_SHIM_EXPLAIN, AGENT_SHIM_ALLOW_AMBIENT_CREDENTIAL, AGENT_SHIM_CATEGORY_OVERRIDE, AGENT_SHIM_ENTRY_OVERRIDE, AGENT_SHIM_HOME, AGENT_SHIM_DEBUG (print stack traces), NO_COLOR. Booleans are true, false, 1 or 0. diff --git a/src/resolve/flatten.test.ts b/src/resolve/flatten.test.ts index b0ed4761..6de266c7 100644 --- a/src/resolve/flatten.test.ts +++ b/src/resolve/flatten.test.ts @@ -219,18 +219,18 @@ describe("phase one: launch flags", () => { it("resolves headroom last-layer-wins like every other launch flag", () => { const flattened = flattenLayers( - [layer(0, { launch: { headroom: true, routeByName: false } }), layer(1, { launch: { headroom: false, routeByName: false } })], + [layer(0, { launch: { headroom: true } }), layer(1, { launch: { headroom: false } })], { home: FAKE_HOME }, ); - expect(flattened.launch).toEqual({ headroom: false, routeByName: false }); + expect(flattened.launch).toEqual({ headroom: false }); }); it("resolves trackUsage last-layer-wins, independently of headroom", () => { const flattened = flattenLayers( - [layer(0, { launch: { trackUsage: true, headroom: true, routeByName: false } }), layer(1, { launch: { trackUsage: false } })], + [layer(0, { launch: { trackUsage: true, headroom: true } }), layer(1, { launch: { trackUsage: false } })], { home: FAKE_HOME }, ); - expect(flattened.launch).toEqual({ trackUsage: false, headroom: true, routeByName: false }); + expect(flattened.launch).toEqual({ trackUsage: false, headroom: true }); }); }); diff --git a/src/resolve/flatten.ts b/src/resolve/flatten.ts index a6c87865..9cfa526f 100644 --- a/src/resolve/flatten.ts +++ b/src/resolve/flatten.ts @@ -20,7 +20,7 @@ function unpackEntryValue(value: EntryValue): { value: boolean; when?: CompiledR export function flattenLayers(layers: readonly Layer[], options: Readonly<{ home: string }>): FlattenedCascade { const categories = new Map(); const rules = new Map(); - const launch: { skipPermissions?: boolean; remoteControl?: boolean; provider?: string; headroom?: boolean; trackUsage?: boolean; claudeVersion?: string; delay?: string; pause?: boolean; explain?: boolean; routeByName?: boolean } = {}; + const launch: { skipPermissions?: boolean; remoteControl?: boolean; provider?: string; headroom?: boolean; trackUsage?: boolean; claudeVersion?: string; delay?: string; pause?: boolean; explain?: boolean } = {}; const diagnostics: Diagnostic[] = []; let models: ModelsConfig | undefined; @@ -62,9 +62,6 @@ export function flattenLayers(layers: readonly Layer[], options: Readonly<{ home if (layer.launch.explain !== undefined) { launch.explain = layer.launch.explain; } - if (layer.launch.routeByName !== undefined) { - launch.routeByName = layer.launch.routeByName; - } } if (layer.models !== undefined) { diff --git a/src/usage/middleware.test.ts b/src/usage/middleware.test.ts index 016289a3..fdb7ead7 100644 --- a/src/usage/middleware.test.ts +++ b/src/usage/middleware.test.ts @@ -23,7 +23,7 @@ const JSON_HEADERS = { "content-type": "application/json" }; function event(overrides: Partial = {}): RoutedResponseEvent { return { - session: { identity: "work", sessionId: "session-1", provider: undefined, headroom: false, routeByName: false, projectId: undefined }, + session: { identity: "work", sessionId: "session-1", provider: undefined, headroom: false, projectId: undefined }, route: "passthrough", method: "POST", path: "/v1/messages", @@ -162,7 +162,7 @@ describe("createUsageMiddleware", () => { it("attributes a provider session's bare /v1/ request to the provider its launch selected", () => { const h = harness(); - const body = begin(h, event({ session: { identity: "work", sessionId: "session-1", provider: "antigravity", headroom: false, routeByName: false, projectId: undefined } })); + const body = begin(h, event({ session: { identity: "work", sessionId: "session-1", provider: "antigravity", headroom: false, projectId: undefined } })); finishBody(h, body); h.flush(); @@ -351,7 +351,7 @@ describe("createUsageMiddleware", () => { it("records a request a routing layer sent to another provider under the provider that served it, not the one the launch selected", () => { const h = harness(); - finishBody(h, begin(h, event({ path: "/v1/messages", servedPath: "/providers/codex/v1/messages", session: { identity: "work", sessionId: "session-1", provider: "router", headroom: false, routeByName: false, projectId: undefined } }))); + finishBody(h, begin(h, event({ path: "/v1/messages", servedPath: "/providers/codex/v1/messages", session: { identity: "work", sessionId: "session-1", provider: "router", headroom: false, projectId: undefined } }))); h.flush(); expect(singleRecord(h)).toMatchObject({ provider: "codex", endpoint: "/v1/messages" }); @@ -359,10 +359,10 @@ describe("createUsageMiddleware", () => { it("omits identity, session and project when the launch resolved none, and records the project when it did", () => { const anonymous = harness(); - finishBody(anonymous, begin(anonymous, event({ session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, routeByName: false, projectId: undefined } }))); + finishBody(anonymous, begin(anonymous, event({ session: { identity: undefined, sessionId: undefined, provider: undefined, headroom: false, projectId: undefined } }))); anonymous.flush(); const scoped = harness(); - finishBody(scoped, begin(scoped, event({ session: { identity: "work", sessionId: "s", provider: undefined, headroom: true, routeByName: false, projectId: "repo-root" } }))); + finishBody(scoped, begin(scoped, event({ session: { identity: "work", sessionId: "s", provider: undefined, headroom: true, projectId: "repo-root" } }))); scoped.flush(); const bare = singleRecord(anonymous);