From 825c27c5396258a55b7ca9a8b1814e522291e972 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 18 Oct 2026 09:27:38 +0100 Subject: [PATCH] feat(statusline): name the model a routing choice served beside the session's own A session whose model something else picks for it (a virtual model's mode, an antigravity tier alias) showed only the name it typed, so what was actually serving was invisible where the person looks. The door now records the choice on the usage record: the name the session asked for, and the distinct providers the choice could have landed on. The statusline draws it as "auto -> glm-5.3", folding the provider in ("auto -> z/glm-5.3") when the choice could cross providers or left the launch's own, replacing the separate via segment in that case. The antigravity relay also names the model that served in message_start instead of echoing the alias, so a usage record attributes quota to the backend model rather than the tier name. --- docs/cli-reference.md | 2 + schema/UsageRecord.schema.json | 9 ++++ src/antigravity/relay.test.ts | 4 +- src/antigravity/relay.ts | 5 +- src/antigravity/route.ts | 5 +- src/codex/route.ts | 2 + src/frontdoor/antigravityMount.test.ts | 72 ++++++++++++++++++++++++++ src/frontdoor/antigravityMount.ts | 4 ++ src/frontdoor/pipeline.test.ts | 41 +++++++++++++-- src/frontdoor/pipeline.ts | 19 +++++-- src/frontdoor/providerRoute.test.ts | 28 ++++++++++ src/frontdoor/providerRoute.ts | 24 ++++++--- src/frontdoor/route.ts | 10 ++++ src/statusline/commands.ts | 14 +++-- src/statusline/enrich.test.ts | 10 ++++ src/statusline/enrich.ts | 5 ++ src/statusline/render.test.ts | 22 ++++++++ src/statusline/render.ts | 14 +++-- src/usage/middleware.test.ts | 40 ++++++++++++++ src/usage/middleware.ts | 2 + src/usage/schema.ts | 4 ++ 21 files changed, 309 insertions(+), 27 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index fce8a5d6..4185f754 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -227,6 +227,8 @@ Where `agent-shim check` resolves one directory+identity's cascade in detail, `a What it draws comes from the payload and from files the client already keeps. The model, the context window's use and the prompt cache's hit ratio come from the payload; the 5h and weekly quota bars come from the payload's own `rate_limits` (claude.ai Pro and Max accounts, after the session's first API response); and the login account with its plan, organisation and seat comes from the `oauthAccount` block of `$CLAUDE_CONFIG_DIR/.claude.json`, or `~/.claude.json` when the configuration directory is not pinned. A session launched through agent-shim adds the identity, the provider, the provider's account and plan, and the provider's own usage windows from the door's usage snapshot; a provider session never shows the payload's windows, which describe the login account rather than the quota it spends. +A session whose model something chose for it shows the choice beside the client's own model name: `auto -> glm-5.3` when a virtual model or a tier alias picked the model that answered, read from the session's last usage record, with the serving provider folded into the arrow (`auto -> z/glm-5.3`) when the choice could have landed on more than one provider or left the launch's own. A session that named a provider-qualified model itself shows `via /` instead, when that name sent it off the launch's provider. + The context bar and the quota windows are laid out on one grid, one or more bars to a row as the terminal's width allows. Bars start in the same column and end in the same column on every row, with each label and set of figures padded to match, and the width left over is shared between the bars; a row with fewer bars than the grid has columns gives its last bar the empty columns' width, so its end still lines up. On a terminal too narrow for the groups to share columns, each group aligns within itself. A session routed through headroom also shows what the proxy saved it (tokens, percentage and dollars) at the end of the first line. The statusline asks the proxy for `GET /stats/sessions/`, keyed by the payload's `session_id`, over the supervised daemon's unix socket (authenticated the way the door's own hop authenticates it) or, when `HEADROOM_PROXY_URL` names a proxy run on its own, over http. A lookup is bounded by a short timeout, and a proxy that is unreachable, slow or does not know the session simply leaves the segment out. diff --git a/schema/UsageRecord.schema.json b/schema/UsageRecord.schema.json index 554360a6..ef65acb1 100644 --- a/schema/UsageRecord.schema.json +++ b/schema/UsageRecord.schema.json @@ -39,6 +39,15 @@ "model": { "type": "string" }, + "requestedModel": { + "type": "string" + }, + "candidateProviders": { + "type": "array", + "items": { + "type": "string" + } + }, "status": { "type": "integer", "minimum": -9007199254740991, diff --git a/src/antigravity/relay.test.ts b/src/antigravity/relay.test.ts index 6b11f8ee..4cd67332 100644 --- a/src/antigravity/relay.test.ts +++ b/src/antigravity/relay.test.ts @@ -10,7 +10,7 @@ describe("createAntigravityRelay", () => { it("relays text streaming chunks to Anthropic SSE frames", () => { let idCounter = 1; const relay = createAntigravityRelay({ - requestedModel: "claude-sonnet-4-5", + servedModel: "claude-sonnet-4-5", toolNames: new Map(), fallbackId: () => `msg_${String(idCounter++)}`, }); @@ -95,7 +95,7 @@ describe("createAntigravityRelay", () => { let idCounter = 1; const toolNames = new Map([["bash_exec", "bash-exec"]]); const relay = createAntigravityRelay({ - requestedModel: "claude-sonnet-4-5", + servedModel: "claude-sonnet-4-5", toolNames, fallbackId: () => `id_${String(idCounter++)}`, }); diff --git a/src/antigravity/relay.ts b/src/antigravity/relay.ts index a2280b3d..1f56464a 100644 --- a/src/antigravity/relay.ts +++ b/src/antigravity/relay.ts @@ -18,7 +18,8 @@ interface AnthropicUsage { } export interface RelayParams { - readonly requestedModel: string; + /** The model that actually serves the request (a tier alias already translated to its concrete backend model), echoed in `message_start` exactly as the real API echoes the concrete model behind an alias, so a usage record read from the stream attributes quota to the backend model. */ + readonly servedModel: string; readonly toolNames: ReadonlyMap; readonly fallbackId: () => string; } @@ -63,7 +64,7 @@ export function createAntigravityRelay(params: RelayParams): { id: messageId, type: "message", role: "assistant", - model: params.requestedModel, + model: params.servedModel, content: [], stop_reason: null, stop_sequence: null, diff --git a/src/antigravity/route.ts b/src/antigravity/route.ts index f8f318cc..0de92c89 100644 --- a/src/antigravity/route.ts +++ b/src/antigravity/route.ts @@ -123,7 +123,8 @@ export function createAntigravityRoute(ports: AntigravityRoutePorts): (request: } } const relay = createAntigravityRelay({ - requestedModel, + // The frame carries the model that actually served the request, as the real API echoes the concrete model behind an alias: a usage record read from the response then attributes quota to the backend model, not the tier alias the session typed. + servedModel: translated.model, toolNames: translated.toolNames, fallbackId, }); @@ -238,6 +239,8 @@ export function createAntigravityRoute(ports: AntigravityRoutePorts): (request: ...quotaHeaders, }, body: stream(), + // The tier alias the session asked for, whenever the translation resolved it to a different model: the front door's mount turns it into the model choice the usage record keeps. + ...(requestedModel !== translated.model ? { requestedModel } : {}), }; }; } diff --git a/src/codex/route.ts b/src/codex/route.ts index 014766cc..267f8f10 100644 --- a/src/codex/route.ts +++ b/src/codex/route.ts @@ -25,6 +25,8 @@ export interface RouteResponse { readonly status: number; readonly headers: Readonly>; readonly body: string | AsyncIterable; + /** The model name the session asked for, when this response answers a request whose model a translation layer chose among alternatives for (a tier alias that resolved to a concrete model) and served under a different name. Read by the front door's mount, never sent to the client. */ + readonly requestedModel?: string; } /** The outcome of looking up the codex provider a request's path names. */ diff --git a/src/frontdoor/antigravityMount.test.ts b/src/frontdoor/antigravityMount.test.ts index 8055ba8a..2f94c810 100644 --- a/src/frontdoor/antigravityMount.test.ts +++ b/src/frontdoor/antigravityMount.test.ts @@ -18,10 +18,12 @@ function createMockResponse(): { getHeaders: () => Record | undefined; getChunks: () => string[]; isEnded: () => boolean; + getNotedModelChoices: () => { requested: string; providers: string[] }[]; } { let status: number | undefined; let headers: Record | undefined; const chunks: string[] = []; + const notedChoices: { requested: string; providers: string[] }[] = []; let ended = false; let headersSent = false; @@ -34,6 +36,9 @@ function createMockResponse(): { headers = h; headersSent = true; }, + noteModelChoice: (choice) => { + notedChoices.push({ requested: choice.requested, providers: [...choice.providers] }); + }, flush: () => undefined, write: async (chunk) => { await Promise.resolve(); @@ -51,6 +56,7 @@ function createMockResponse(): { getHeaders: () => headers, getChunks: () => chunks, isEnded: () => ended, + getNotedModelChoices: () => notedChoices, }; } @@ -163,3 +169,69 @@ describe("createAntigravityRouteMount", () => { expect(mockRes.getChunks().join("")).toContain("request body chunk was not a Buffer"); }); }); + +describe("a tier alias the translator resolved", () => { + it("is noted as a choice among this provider's models, and the frame names the model that served", async () => { + const geminiSse = [ + 'data: {"candidates":[{"content":{"parts":[{"text":"Greetings!"}]}}]}\n\n', + 'data: {"candidates":[{"finishReason":"STOP"}]}\n\n', + ].join(""); + + const mockFetch: typeof fetch = async () => { + return await Promise.resolve( + new Response(geminiSse, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ); + }; + + const mount = createAntigravityRouteMount( + { + loadProvider: () => ({ + ok: true, + config: DEFAULT_CONFIG, + token: "dummy-key", + credentialTarget: "apiKey", + }), + fetchFn: mockFetch, + }, + "http://127.0.0.1:4100", + "my-ag", + ); + + const mockRes = createMockResponse(); + const reqBody = JSON.stringify({ + model: "auto", + max_tokens: 100, + messages: [{ role: "user", content: "Hi" }], + }); + + const request: RoutedRequest = { + method: "POST", + url: "/providers/my-ag/v1/messages", + headers: {}, + body: Readable.from([Buffer.from(reqBody)]), + signal: new AbortController().signal, + session: { + identity: undefined, + sessionId: undefined, + provider: undefined, + headroom: false, routeByName: false, + projectId: undefined, + }, + }; + + await mount.serve(request, mockRes.response); + + expect(mockRes.getStatus()).toBe(HTTP_STATUS.ok); + // The alias the session typed, marked as a choice that never left this provider. + expect(mockRes.getNotedModelChoices()).toEqual([{ requested: "auto", providers: ["my-ag"] }]); + // The frame names the model that actually served, not the alias, so a usage record read from the stream attributes quota to the backend model. + const output = mockRes.getChunks().join(""); + const start = output.indexOf("event: message_start"); + expect(start).toBeGreaterThanOrEqual(0); + expect(output.slice(start, output.indexOf("event:", start + 1))).not.toContain('"model": "auto"'); + expect(output).toContain("Greetings!"); + }); +}); diff --git a/src/frontdoor/antigravityMount.ts b/src/frontdoor/antigravityMount.ts index 23c62c35..806b71f0 100644 --- a/src/frontdoor/antigravityMount.ts +++ b/src/frontdoor/antigravityMount.ts @@ -73,6 +73,10 @@ export function createAntigravityRouteMount(ports: AntigravityRoutePorts, upstre if (request.signal.aborted) { return; } + // A tier alias the translator resolved to a concrete model is a choice among this provider's lineup: recorded before the head is written so the usage middleware sees it. The candidates never leave this provider, so the provider list is exactly it. + if (routed.requestedModel !== undefined) { + response.noteModelChoice?.({ requested: routed.requestedModel, providers: [provider] }); + } await writeRouteResponse(routed, response, request.signal); }, }; diff --git a/src/frontdoor/pipeline.test.ts b/src/frontdoor/pipeline.test.ts index 981f107b..b20720cb 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 } } }); + 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 } } }); 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 } } }); + 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 } } }); routed.start(HTTP_STATUS.ok, {}); let resolved = false; const write = routed.write("chunk").then(() => { @@ -338,7 +338,7 @@ function routedWith(response: FakeServerResponse, observers: readonly ResponseOb deps: d, 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 } }, + request: { method: "POST", path: "/v1/messages", receivedAt: 0, viaHeadroom: false, thread: { lineage: undefined }, modelChoice: { value: undefined } }, servedPath: "/v1/messages", }); return { routed, logs }; @@ -416,3 +416,38 @@ describe("response body observers", () => { expect(logs.join("\n")).toContain("scanner bug"); }); }); + +describe("a model choice reaching the response observers", () => { + it("carries the resolution's choice on the event the observers see at the head", async () => { + const choices: (RoutedResponseEvent["modelChoice"])[] = []; + const { request, response } = pipelineRequest(); + await serveRouted(request, deps({ ok: true, route: recordingRoute(), modelChoice: { requested: "auto", providers: ["z", "antigravity"] } }, [(event) => { + choices.push(event.modelChoice); + return undefined; + }]).deps); + expect(response.writtenHead?.status).toBe(HTTP_STATUS.ok); + expect(choices).toEqual([{ requested: "auto", providers: ["z", "antigravity"] }]); + }); + + it("carries a choice the route notes before its head, as the tier translator does", async () => { + const choices: (RoutedResponseEvent["modelChoice"])[] = []; + const notingRoute: FrontDoorRoute = { + name: "noting", + headroomEligible: false, + headroomUpstream: undefined, + serve: async (_request, response) => { + response.noteModelChoice?.({ requested: "sonnet", providers: ["antigravity"] }); + response.start(HTTP_STATUS.ok, {}); + await Promise.resolve(); + response.end(); + }, + }; + const { request, response } = pipelineRequest(); + await serveRouted(request, deps({ ok: true, route: notingRoute }, [(event) => { + choices.push(event.modelChoice); + return undefined; + }]).deps); + expect(response.writtenHead?.status).toBe(HTTP_STATUS.ok); + expect(choices).toEqual([{ requested: "sonnet", providers: ["antigravity"] }]); + }); +}); diff --git a/src/frontdoor/pipeline.ts b/src/frontdoor/pipeline.ts index 789785e3..74c1b57d 100644 --- a/src/frontdoor/pipeline.ts +++ b/src/frontdoor/pipeline.ts @@ -4,7 +4,7 @@ import { HTTP_STATUS } from "../codex/http"; import type { HeadroomDecision } from "./headroomDecision"; import { applyHeadroomHop, headroomReady, type HeadroomHopDeps } from "./headroomHop"; import type { Lineage } from "./lineage"; -import { identifyRequest, type FrontDoorRoute, type RoutedRequest, type RoutedResponse, type SessionIdentity } from "./route"; +import { identifyRequest, type FrontDoorRoute, type ModelChoice, type RoutedRequest, type RoutedResponse, type SessionIdentity } from "./route"; /** What one routed response looked like as it started back to the client: the typed event response middleware observes. */ export interface RoutedResponseEvent { @@ -23,6 +23,8 @@ export interface RoutedResponseEvent { readonly viaHeadroom: boolean; /** Where the request sits in its conversation thread, known once the hop has read the body. Undefined for a request that did not cross the hop or was not a messages request. */ readonly lineage: Lineage | undefined; + /** The model choice this response answers, when a routing layer chose among alternatives for the request's model name (a virtual model's or a tier alias's selection): the name the session asked for and the providers it could have landed on. Filled before the head is written, by the resolution or by the serving route, so middleware reading it at the head always sees it. */ + readonly modelChoice: ModelChoice | undefined; readonly status: number; readonly headers: Readonly>; } @@ -47,7 +49,7 @@ export type ResponseObserver = (event: RoutedResponseEvent) => ResponseBodyObser /** The outcome of resolving a request's target to a route. */ export type RouteResolution = - | { readonly ok: true; readonly route: FrontDoorRoute; /** The request this resolution serves, when resolving it rewrote the original (a routed request carries a body the route's own scan replayed): consumers that read the body again, the headroom hop above all, must read this one, because the original's remainder is teed into the replay and stalls once the replay's buffer fills. Absent when the resolution serves the original request as received. */ readonly request?: RoutedRequest } + | { readonly ok: true; readonly route: FrontDoorRoute; /** The request this resolution serves, when resolving it rewrote the original (a routed request carries a body the route's own scan replayed): consumers that read the body again, the headroom hop above all, must read this one, because the original's remainder is teed into the replay and stalls once the replay's buffer fills. Absent when the resolution serves the original request as received. */ readonly request?: RoutedRequest; /** The model choice this resolution made, when resolving the request's model name chose among alternatives (a virtual model's or a family's selection through its mode) and served it under a member's own name. */ readonly modelChoice?: ModelChoice } | { readonly ok: false; readonly status: number; readonly message: string }; /** Everything the ordered pipeline depends on, injected so it runs against fakes in tests. */ @@ -128,6 +130,8 @@ interface ObservedRequest { readonly viaHeadroom: boolean; /** Filled by the hop once it has read the body; read when the response head is written, which is after. */ readonly thread: { lineage: Lineage | undefined }; + /** The model choice a routing layer made for this request, when it made one: filled by the resolution or by the serving route before the head is written, and read by the observers the head invokes. */ + readonly modelChoice: { value: ModelChoice | undefined }; } /** The request target's path without its query string: the query is the client's business, and middleware has no use for it. */ @@ -197,7 +201,7 @@ export function createRoutedResponse( start: (status, headers) => { for (const observe of deps.responseObservers) { try { - const body = observe({ session, route, method: request.method, path: request.path, servedPath: context.servedPath, receivedAt: request.receivedAt, viaHeadroom: request.viaHeadroom, lineage: request.thread.lineage, status, headers }); + const body = observe({ session, route, method: request.method, path: request.path, servedPath: context.servedPath, receivedAt: request.receivedAt, viaHeadroom: request.viaHeadroom, lineage: request.thread.lineage, modelChoice: request.modelChoice.value, status, headers }); if (body !== undefined) { bodyObservers.push(body); } @@ -209,6 +213,9 @@ export function createRoutedResponse( response.writeHead(status, headers); headersSent = true; }, + noteModelChoice: (choice) => { + request.modelChoice.value = choice; + }, flush: () => { response.flushHeaders(); }, @@ -245,7 +252,7 @@ export function createRoutedResponse( * The ordered pipeline every routed session's request runs through: identify the session (splitting the launcher-injected headers out of what may leave the machine), then route, with the response middleware running at each response head. A route that cannot be resolved is answered as an Anthropic-shaped error, and the observers see that error like any other response, since quota and rate-limit state lives in exactly those. */ export async function serveRouted(request: PipelineRequest, deps: PipelineDeps): Promise { - const observed: ObservedRequest = { method: request.method, path: pathOf(request.url), receivedAt: deps.now(), viaHeadroom: false, thread: { lineage: undefined } }; + const observed: ObservedRequest = { method: request.method, path: pathOf(request.url), receivedAt: deps.now(), viaHeadroom: false, thread: { lineage: undefined }, modelChoice: { value: undefined } }; const identified = identifyRequest(request.headers); const admission = deps.admit({ url: request.url, headers: request.headers, forwardable: identified.forwardableHeaders }); if (!admission.ok) { @@ -272,6 +279,10 @@ export async function serveRouted(request: PipelineRequest, deps: PipelineDeps): return; } const { headroom } = deps; + // The resolution's model choice reaches the observers the same way a serving route's does: stashed on the observed request before any head is written. + if (resolution.modelChoice !== undefined) { + observed.modelChoice.value = resolution.modelChoice; + } // Only the client-facing pipeline decides: a request coming back on the direct listener has already been through that decision, and asking again from there would let a daemon-less pipeline overwrite the session's choice. const wantsHeadroom = headroom !== undefined && diff --git a/src/frontdoor/providerRoute.test.ts b/src/frontdoor/providerRoute.test.ts index d9fa787b..61a7331d 100644 --- a/src/frontdoor/providerRoute.test.ts +++ b/src/frontdoor/providerRoute.test.ts @@ -838,3 +838,31 @@ 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 }, + }); + const files: Record = { + [`${PROVIDERS_DIR}/z.json`]: httpProvider, + [`${PROVIDERS_DIR}/ag.json`]: antigravityProvider, + }; + + it("carries the name the session asked for and every provider its members span", async () => { + const models: ModelsConfig = { virtual: { pick: { mode: "ordered", members: ["z/glm-5.3", "ag/gemini-2.5-pro"] } } }; + const resolution = await resolver(files, OWN_PORT, models)(sessionRequest("pick")); + expect(resolution.ok).toBe(true); + if (resolution.ok) { + expect(resolution.modelChoice).toEqual({ requested: "pick", providers: ["z", "ag"] }); + } + }); + + it("is absent for a qualified name the session picked itself", async () => { + const resolution = await resolver(files)(sessionRequest("ag/gemini-2.5-pro")); + expect(resolution.ok).toBe(true); + if (resolution.ok) { + expect(resolution.modelChoice).toBeUndefined(); + } + }); +}); diff --git a/src/frontdoor/providerRoute.ts b/src/frontdoor/providerRoute.ts index 54d32b8a..aeaedfbd 100644 --- a/src/frontdoor/providerRoute.ts +++ b/src/frontdoor/providerRoute.ts @@ -23,7 +23,7 @@ import { FAMILY_PREFIX, buildFamilies, expandFamilyMembers, familyCapabilities, import { withoutContextMarker } from "../models/identity"; import type { TargetObservation } from "../selection/signals"; import type { UsageSnapshot } from "../usage/schema"; -import { PROVIDER_PATH_PREFIX, directOrigin, parseProviderPath, type FrontDoorRoute, type RoutedRequest } from "./route"; +import { PROVIDER_PATH_PREFIX, directOrigin, parseProviderPath, type FrontDoorRoute, type ModelChoice, type RoutedRequest } from "./route"; import { Readable } from "node:stream"; import { scanRequestHead } from "./requestScan"; @@ -47,11 +47,13 @@ function scopeSessionProviderPath(request: RoutedRequest): RoutedRequest { return { ...request, url: `${PROVIDER_PATH_PREFIX}${encodeURIComponent(request.session.provider)}${request.url}` }; } -/** What a request's model name resolves to when it routes by name: the provider that serves it, the model name that provider serves it under (a qualified name loses its qualifier, a virtual model resolves to the member its mode chose), and the member ref the choice landed on, for the stickiness the next request of the same session reads. */ +/** What a request's model name resolves to when it routes by name: the provider that serves it, the model name that provider serves it under (a qualified name loses its qualifier, a virtual model resolves to the member its mode chose), and the member ref the choice landed on, for the stickiness the next request of the same session reads. A virtual model or family selection also carries the name the session asked for and the distinct providers its members span, so the response can tell the middleware a choice among alternatives happened. */ interface RoutedModel { readonly provider: string; readonly model: string; readonly ref: string; + readonly requested?: string; + readonly providers?: readonly string[]; } /** What `resolveRoutedModel` reads from the door: the session's models block, the quota snapshot of its identity, what each target's recent requests observed, and the targets the providers offer (which families are formed from). */ @@ -122,7 +124,9 @@ function resolveRoutedModel( } const [provider, ...rest] = selection.ref.split("/"); const chosen = rest.join("/"); - return provider === undefined || chosen === "" ? undefined : { provider, model: withoutContextMarker(chosen), ref: selection.ref }; + // The choice itself is part of the answer: the virtual or family name the session asked for, and the distinct providers its members span (one entry when they never leave a single provider), for the response middleware to record. + const providers = [...new Set(members.map((ref) => ref.split("/")[0] ?? ""))].filter((name) => name !== ""); + return provider === undefined || chosen === "" ? undefined : { provider, model: withoutContextMarker(chosen), ref: selection.ref, requested: model, providers }; } /** The identity and model an `acct:` member names, when the model a session asked for is one (`acct:work/opus` directly, or a virtual model whose first member is). One login per process, so no route can serve it: only a relaunch under the identity can. */ @@ -340,6 +344,8 @@ export function createProviderRouteResolver(deps: { let replay: RoutedRequest | undefined; // Set once a model name has been resolved to its target: the name the target now carries is its own (a provider may serve a model named like `hf:zai-org/GLM-5.1`, which would read as a qualified ref), so the target's turn of the loop serves it as it is and never resolves it by name again. let nameResolved = false; + // The model choice a virtual model or family made, carried to the resolution so the response middleware can record it; the loop's later turns (the target's own resolution) serve the chosen name as it is and leave this untouched. + let modelChoice: ModelChoice | undefined; const visited = new Set(); for (;;) { const path = new URL(current.url, "http://127.0.0.1").pathname; @@ -394,10 +400,14 @@ export function createProviderRouteResolver(deps: { const requested = scan.model; if (routed !== undefined) { routingMemory.set(current.session.sessionId ?? "", routed.ref); + // A virtual model's or family's selection is a choice among alternatives, unlike a qualified name the session picked itself: only that choice is recorded for the response middleware. + if (routed.requested !== undefined) { + modelChoice = { requested: routed.requested, providers: routed.providers ?? [routed.provider] }; + } if (requested !== undefined && routed.provider === providerName && routed.model === requested) { // The name points back at the provider the session is already on under the name it serves: nothing to rewrite, serve it here. const resolution = resolve(current); - return resolution.ok ? { ok: true, route: servingRewritten(resolution.route, { ...replay, url: current.url }), request: { ...replay, url: current.url } } : resolution; + return resolution.ok ? { ok: true, route: servingRewritten(resolution.route, { ...replay, url: current.url }), request: { ...replay, url: current.url }, ...(modelChoice === undefined ? {} : { modelChoice }) } : resolution; } if (requested !== undefined && routed.model !== requested) { // The rewrite replaces the model field's value in the one copy of the body the whole-body scan already holds, so the target receives exactly the request that matched with exactly one field changed; the framing header is dropped because the new name is a different length. @@ -416,13 +426,13 @@ export function createProviderRouteResolver(deps: { } // Same provider, renamed model: serve the rewritten body here, on the provider the session is already on. const resolution = resolve(current); - return resolution.ok ? { ok: true, route: servingRewritten(resolution.route, { ...replay, url: current.url }), request: { ...replay, url: current.url } } : resolution; + return resolution.ok ? { ok: true, route: servingRewritten(resolution.route, { ...replay, url: current.url }), request: { ...replay, url: current.url }, ...(modelChoice === undefined ? {} : { modelChoice }) } : resolution; } } if (routes === undefined || routes.length === 0) { const resolution = resolve(current); return resolution.ok && replay !== undefined - ? { ok: true, route: servingRewritten(resolution.route, { ...replay, url: current.url }), request: { ...replay, url: current.url } } + ? { ok: true, route: servingRewritten(resolution.route, { ...replay, url: current.url }), request: { ...replay, url: current.url }, ...(modelChoice === undefined ? {} : { modelChoice }) } : resolution.ok ? serveScoped(resolution) : resolution; @@ -466,7 +476,7 @@ export function createProviderRouteResolver(deps: { } if (matched === undefined) { const resolution = resolve(current); - return resolution.ok ? { ok: true, route: servingRewritten(resolution.route, { ...replay, url: current.url }), request: { ...replay, url: current.url } } : resolution; + return resolution.ok ? { ok: true, route: servingRewritten(resolution.route, { ...replay, url: current.url }), request: { ...replay, url: current.url }, ...(modelChoice === undefined ? {} : { modelChoice }) } : resolution; } if (matched.model !== undefined && scan.text !== undefined && scan.model !== undefined) { // The rewrite replaces the model field's value in the one copy of the body the whole-body scan already holds, so the target receives exactly the request that matched with exactly one field changed. diff --git a/src/frontdoor/route.ts b/src/frontdoor/route.ts index d2257139..2d000805 100644 --- a/src/frontdoor/route.ts +++ b/src/frontdoor/route.ts @@ -189,6 +189,16 @@ export interface RoutedResponse { readonly end: () => void; /** Drops the connection: an upstream failure after the head went out, where no status can be sent any more. */ readonly destroy: () => void; + /** + * Records that this response answers a request whose model name a routing layer chose among alternatives for: the name the session asked for and the distinct providers the choice could have landed on. Must be called before `start`, so the response observers the head invokes see it. Optional because only a route that translates a dynamic model name itself (the Antigravity tier translator) knows the pairing at serve time; a resolver-level choice reaches the observers through the resolution instead. + */ + readonly noteModelChoice?: (choice: ModelChoice) => void; +} + +/** What a routing layer that chose a model among alternatives tells the response middleware: the name the session asked for, and the distinct providers the choice could have landed on (one entry when it never left a single provider). */ +export interface ModelChoice { + readonly requested: string; + readonly providers: readonly string[]; } /** One pluggable destination the front door routes a request to. */ diff --git a/src/statusline/commands.ts b/src/statusline/commands.ts index 79f29350..1ccf5b91 100644 --- a/src/statusline/commands.ts +++ b/src/statusline/commands.ts @@ -112,20 +112,26 @@ export function registerStatuslineCommand(program: Command, deps: { readonly pat const target = headroomTargetOf(process.env, socketTarget === undefined || socketTarget.refused !== undefined ? undefined : socketTarget.socketPath); const sessionId = headroomSessionIdOf(payload, process.env); const headroom = target === undefined || sessionId === undefined ? undefined : await fetchHeadroomSessionStats(realHeadroomGet, target, sessionId); - // The target this session's last request was served by, from the usage log's recent records for its session id: what shows a routing session's virtual-model choice where the person is looking. A record only names something other than the launch's own provider when routing sent it there. + // The target this session's last request was served by, from the usage log's recent records for its session id: what shows a routing session's virtual-model choice where the person is looking. A record only names something other than the launch's own provider when routing sent it there. A record that also names the model the session asked for was served by a choice among alternatives (a virtual model's or a tier alias's selection), which the statusline draws beside the name the session typed. const sessionHeader = /x-agent-shim-session:\s*([A-Za-z0-9@._-]+)/i.exec(process.env.ANTHROPIC_CUSTOM_HEADERS ?? "")?.[1]; let routedTarget: string | undefined; + let modelChoice: { readonly requested: string; readonly served: string; readonly provider: string; readonly providers: readonly string[] } | undefined; if (sessionHeader !== undefined) { try { const last = readUsageLog(realFarmFs, deps.paths.usageLogDir, { sinceMs: nowMs - ROUTED_TARGET_LOOKBACK_MS }).records.filter((record) => record.sessionId === sessionHeader).at(-1); - if (last?.model !== undefined && last.provider !== providerName) { - routedTarget = `${last.provider}/${last.model}`; + if (last?.model !== undefined) { + if (last.provider !== providerName) { + routedTarget = `${last.provider}/${last.model}`; + } + if (last.requestedModel !== undefined && last.requestedModel !== last.model) { + modelChoice = { requested: last.requestedModel, served: last.model, provider: last.provider, providers: last.candidateProviders ?? [] }; + } } } catch { routedTarget = undefined; } } - const { payload: enriched, summary } = enrichStatuslinePayload({ payload, env: process.env, snapshot, account, providerName, nowMs, headroom, routedTarget }); + const { payload: enriched, summary } = enrichStatuslinePayload({ payload, env: process.env, snapshot, account, providerName, nowMs, headroom, routedTarget, ...(modelChoice === undefined ? {} : { modelChoice }) }); const renderer = process.env.AGENT_SHIM_STATUSLINE_CMD; if (renderer !== undefined && renderer !== "") { const child = spawn("/bin/sh", ["-c", renderer], { stdio: ["pipe", "inherit", "inherit"] }); diff --git a/src/statusline/enrich.test.ts b/src/statusline/enrich.test.ts index 3ecddede..e5c003d0 100644 --- a/src/statusline/enrich.test.ts +++ b/src/statusline/enrich.test.ts @@ -294,3 +294,13 @@ describe("enrichStatuslinePayload", () => { expect(summary.loginSeatTier).toBe("team_tier_1"); }); }); + +describe("a model choice among alternatives", () => { + it("threads the choice through to the summary and the enriched payload", () => { + const modelChoice = { requested: "auto", served: "glm-5.3", provider: "z", providers: ["z", "antigravity"] }; + const { payload, summary } = enrichStatuslinePayload({ payload: {}, env: {}, snapshot: undefined, account: undefined, providerName: undefined, nowMs: NOW_MS, headroom: undefined, modelChoice }); + expect(summary.modelChoice).toEqual(modelChoice); + const enriched = payload as { agent_shim?: { modelChoice?: unknown } }; + expect(enriched.agent_shim?.modelChoice).toEqual(modelChoice); + }); +}); diff --git a/src/statusline/enrich.ts b/src/statusline/enrich.ts index 9ff103a2..c2bf17f0 100644 --- a/src/statusline/enrich.ts +++ b/src/statusline/enrich.ts @@ -31,6 +31,8 @@ export interface StatuslineSummary { readonly headroom: HeadroomStats | undefined; /** The target the session's last request was served by, `provider/model`, when routing by name sent it somewhere other than the launch's own provider or under another name; what makes a virtual model's choice visible where the person is looking. Absent when the caller cannot read the usage log. */ readonly routedTarget?: string | undefined; + /** The model choice the session's last request was served by, when a routing layer chose among alternatives for its model name (a virtual model's or a tier alias's selection): the name the session asked for, the model and provider that answered, and the distinct providers the choice could have landed on. Absent when the last request named its model explicitly. */ + readonly modelChoice?: { readonly requested: string; readonly served: string; readonly provider: string; readonly providers: readonly string[] }; } /** One of a provider's quota windows, in the form the statusline draws. */ @@ -184,6 +186,8 @@ export interface EnrichParams { readonly headroom: HeadroomStats | undefined; /** The target the session's last request was served by, `provider/model`, when routing by name sent it somewhere other than the launch's own provider or under another name; what makes a virtual model's choice visible where the person is looking. Absent when the caller cannot read the usage log. */ readonly routedTarget?: string | undefined; + /** The model choice the session's last request was served by, when a routing layer chose among alternatives for its model name: see `StatuslineSummary.modelChoice`. */ + readonly modelChoice?: { readonly requested: string; readonly served: string; readonly provider: string; readonly providers: readonly string[] }; } /** @@ -215,6 +219,7 @@ export function enrichStatuslinePayload(params: EnrichParams): { readonly payloa quotaFamilies: [], headroom: params.headroom, routedTarget: params.routedTarget, + ...(params.modelChoice === undefined ? {} : { modelChoice: params.modelChoice }), }; if (params.snapshot !== undefined) { const scopedName = providerScopedState(params.snapshot, params.providerName, params.env.AGENT_SHIM_PROVIDER); diff --git a/src/statusline/render.test.ts b/src/statusline/render.test.ts index d9b095d5..9e0a0683 100644 --- a/src/statusline/render.test.ts +++ b/src/statusline/render.test.ts @@ -289,3 +289,25 @@ describe("renderStatuslineBlock", () => { ).toBe(""); }); }); + +describe("a dynamic mode's choice beside the model name", () => { + it("draws the served model beside the name the session typed, naming the provider when the choice could cross providers", () => { + const summary: StatuslineSummary = { ...SUMMARY, modelChoice: { requested: "auto", served: "glm-5.3", provider: "z", providers: ["z", "antigravity"] } }; + const text = stripAnsi(renderStatuslineBlock(summary, VIEW, WIDE_TERMINAL, NOW_MS)); + expect(text).toContain("Sonnet 5.5 -> z/glm-5.3"); + }); + + it("draws the served model alone when the choice never left one provider", () => { + const summary: StatuslineSummary = { ...SUMMARY, modelChoice: { requested: "auto", served: "gemini-4-argon-medium", provider: "antigravity", providers: [] } }; + const text = stripAnsi(renderStatuslineBlock(summary, VIEW, WIDE_TERMINAL, NOW_MS)); + expect(text).toContain("Sonnet 5.5 -> gemini-4-argon-medium"); + expect(text).not.toContain("z/glm"); + }); + + it("folds the served provider into the arrow instead of a separate via segment when routing left the launch provider", () => { + const summary: StatuslineSummary = { ...SUMMARY, routedTarget: "z/glm-5.3", modelChoice: { requested: "auto", served: "glm-5.3", provider: "z", providers: ["z"] } }; + const text = stripAnsi(renderStatuslineBlock(summary, VIEW, WIDE_TERMINAL, NOW_MS)); + expect(text).toContain("Sonnet 5.5 -> z/glm-5.3"); + expect(text).not.toContain("via "); + }); +}); diff --git a/src/statusline/render.ts b/src/statusline/render.ts index da8c73bf..52292768 100644 --- a/src/statusline/render.ts +++ b/src/statusline/render.ts @@ -254,15 +254,21 @@ function layoutHeader(texts: readonly string[], maxWidth: number, separator: str */ export function renderStatuslineBlock(summary: StatuslineSummary, view: StatuslinePayloadView, terminalWidth: number, nowMs: number): string { const header: string[] = []; - if (view.modelDisplayName !== undefined && view.modelDisplayName !== "") { - header.push(view.modelDisplayName); + // A dynamic mode's selection draws the model that answered beside the name the session typed (`auto -> glm-5.3`), because the client's own model name alone says nothing about what is actually serving. The provider joins the arrow when the choice could have landed on more than one, or when it left the launch's provider: exactly the cases where the provider is part of the answer. + const choiceNamesProvider = summary.modelChoice !== undefined && (summary.modelChoice.providers.length > 1 || summary.routedTarget !== undefined); + const modelSegment = + summary.modelChoice !== undefined && view.modelDisplayName !== undefined && view.modelDisplayName !== "" + ? `${view.modelDisplayName} -> ${choiceNamesProvider ? `${summary.modelChoice.provider}/${summary.modelChoice.served}` : summary.modelChoice.served}` + : view.modelDisplayName; + if (modelSegment !== undefined && modelSegment !== "") { + header.push(modelSegment); } if (summary.provider !== undefined) { const account = [summary.providerAccountEmail, summary.providerPlan].filter((part) => part !== undefined); header.push(account.length > 0 ? `${summary.provider} · ${account.join(" · ")}` : summary.provider); } - // The target the last request was served by, when routing by name sent it off the launch's provider: a virtual model's or a qualified name's choice, shown where the person is looking rather than only in the usage log. - if (summary.routedTarget !== undefined) { + // The target the last request was served by, when routing by name sent it off the launch's provider: a qualified name's choice, shown where the person is looking rather than only in the usage log. A dynamic mode's arrow already names the provider in this case, so the two never repeat each other. + if (summary.routedTarget !== undefined && !choiceNamesProvider) { header.push(`via ${summary.routedTarget}`); } // An account in an organisation is on the organisation's plan with its seat's tier naming the variant, and the seat's rate-limit tier names limits, not the plan; a personal account's plan is the tier itself. A personal plan's organizationName is only the account holder's own name, redundant beside the email, so an organisation's name shows only for the organisation types. Beside a provider the login's plan names quota the session is not drawing from, so there the login names its organisation, or just its account when it has none. diff --git a/src/usage/middleware.test.ts b/src/usage/middleware.test.ts index 21d50ff3..b09aeeed 100644 --- a/src/usage/middleware.test.ts +++ b/src/usage/middleware.test.ts @@ -31,6 +31,7 @@ function event(overrides: Partial = {}): RoutedResponseEven receivedAt: RECEIVED_AT, viaHeadroom: false, lineage: undefined, + modelChoice: undefined, status: OK_STATUS, headers: SSE_HEADERS, ...overrides, @@ -494,3 +495,42 @@ describe("createUsageMiddleware", () => { }); }); }); + +describe("a model choice among alternatives", () => { + it("records the name the session asked for, with the candidate providers when the choice could cross providers", () => { + const h = harness(); + const body = begin(h, event({ modelChoice: { requested: "auto", providers: ["z", "antigravity"] } })); + + body.chunk(bytes(STREAM_BODY)); + finishBody(h, body); + h.flush(); + + const record = singleRecord(h); + expect(record.requestedModel).toBe("auto"); + expect(record.candidateProviders).toEqual(["z", "antigravity"]); + }); + + it("records a choice that never left one provider without candidate providers", () => { + const h = harness(); + const body = begin(h, event({ modelChoice: { requested: "sonnet", providers: ["antigravity"] } })); + + body.chunk(bytes(STREAM_BODY)); + finishBody(h, body); + h.flush(); + + const record = singleRecord(h); + expect(record.requestedModel).toBe("sonnet"); + expect(record.candidateProviders).toBeUndefined(); + }); + + it("records no choice for a request whose model name reached the provider unchanged", () => { + const h = harness(); + const body = begin(h, event()); + + body.chunk(bytes(STREAM_BODY)); + finishBody(h, body); + h.flush(); + + expect(singleRecord(h).requestedModel).toBeUndefined(); + }); +}); diff --git a/src/usage/middleware.ts b/src/usage/middleware.ts index 514ed26f..316797e2 100644 --- a/src/usage/middleware.ts +++ b/src/usage/middleware.ts @@ -93,6 +93,8 @@ function buildRecord(event: RoutedResponseEvent, context: { readonly headAt: num method: event.method, endpoint, ...(scan?.model === undefined ? {} : { model: scan.model }), + ...(event.modelChoice === undefined ? {} : { requestedModel: event.modelChoice.requested }), + ...(event.modelChoice !== undefined && event.modelChoice.providers.length > 1 ? { candidateProviders: [...event.modelChoice.providers] } : {}), status: event.status, latencyMs: Math.max(0, Math.round(context.headAt - event.receivedAt)), durationMs: Math.max(0, Math.round(context.endedAt - event.receivedAt)), diff --git a/src/usage/schema.ts b/src/usage/schema.ts index bbeb631e..cf1f133f 100644 --- a/src/usage/schema.ts +++ b/src/usage/schema.ts @@ -65,6 +65,10 @@ export const UsageRecordSchema = z.strictObject({ endpoint: z.string(), /** The model the response named. */ model: z.string().optional(), + /** The model name the session asked for, when a routing layer chose among alternatives for it (a virtual model's or a tier alias's selection) and served the request under a different name. Absent when the session's own name reached the provider unchanged. */ + requestedModel: z.string().optional(), + /** The distinct providers the choice could have landed on, present only when there was more than one, so a reader can tell a choice that crossed providers from one that stayed inside a single provider's lineup. */ + candidateProviders: z.array(z.string()).optional(), status: z.number().int(), /** Milliseconds from the request's arrival to its response head. */ latencyMs: z.number().int().nonnegative(),