diff --git a/docs/configuration-model.md b/docs/configuration-model.md index b5ace67a..8965437f 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 stays on the current model while it stands, `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 every launch, overriding the per-provider tier tables, and `--clear` returns the tier to them. 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: 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. A session that routes by name lists the catalogue in `/model`: its virtual models first, then every provider's models qualified (`z / glm-5.3`), honouring the block's `hide` and `show` lists. A launch turns the block on with `launch.routeByName` (`--[no-]route-by-name`, `AGENT_SHIM_ROUTE_BY_NAME`, a profile's `--launch-route-by-name`, a directory rule or a committed file): a provider session then sends a provider-qualified model name (`m/glm-5.3`) or a virtual model's name to the provider that serves it instead of its own, and a `router` provider (a kind with no endpoint or credential) does the same whatever it launches with. A provider-less (OAuth) session routes qualified names the same way while its bare names still go 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, so a provider whose credential yields no token fails the launch naming it instead of the first request that names it. 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 stays on the current model while it stands, `lowest-latency` and `highest-success` serve from the observed best, and `cheapest` ranks by the `model price` entries weighted by the tokens the session has actually been sending). A member names a provider model (`z/glm-5.3`), an account model (`acct:work/opus`), another virtual model (cycles are refused), or a whole model family (`family:`, every provider and account offering it). `agent-shim model tier opus --ref fast` points a Claude tier at one of these refs: for a session that routes by name the tier's model setting (`ANTHROPIC_DEFAULT_OPUS_MODEL` and its siblings) becomes the ref, which the door resolves per request, overriding that provider's own tier table for the tier, and `--clear` returns the tier to the table. A session that does not route by name keeps its provider's table, because only the door's name resolution can serve a ref. Nothing reads the block until a launch selects it, so a defined-but-unselected model changes nothing. `doctor` checks the block's own consistency: members naming no defined provider, virtual model or identity, cycles, a `cheapest` member with no prices, and a ref both hidden and shown. The block cascades: the global block is the shallowest layer, and a configuration profile, a directory rule and a committed `.agent-shim.json` carry the same `models` key in a layer form where any definition may be `null` to remove what the shallower layers set (`hide` and `show` replace wholesale), and the deepest layer naming a definition wins. The result is what a launch lists in `/model`, checks credentials for and applies tier pointers from; when a deeper layer changed it, the launch records the block it resolved in its session's registry entry and the door resolves that session's names against it, and when none did the door follows the global block live. A session that routes by name lists the catalogue in `/model`: its virtual models first, then one row per model family offered by more than one target (its description names every target, and selecting it lets the family's mode choose among them), then every provider's models qualified (`z / glm-5.3`), honouring the block's `hide` and `show` lists. A launch turns the block on with `launch.routeByName` (`--[no-]route-by-name`, `AGENT_SHIM_ROUTE_BY_NAME`, a profile's `--launch-route-by-name`, a directory rule or a committed file): a provider session then sends a provider-qualified model name (`m/glm-5.3`) or a virtual model's name to the provider that serves it instead of its own, and a `router` provider (a kind with no endpoint or credential) does the same whatever it launches with. A provider-less (OAuth) session routes qualified names the same way while its bare names still go to Anthropic, and its `/model` rows follow Claude Code's own rather than replacing them. The front door takes a virtual model's choice per request, through its mode over the live signals: the quota windows the session's identity last recorded for each member's provider, the latency, success and token mix each target's recent requests observed (seeded from the usage log at door start), and the block's prices, so an edited block or a spent window changes the next request's answer with no restart. Whatever the request named, the target receives its own model name: a qualified or virtual name is rewritten in the request's one field, exactly as a provider's own `routes` entry that names a model is. A launch with routing on also resolves every provider the block names before it starts, a `family:` member included (through every provider offering a target of the family), so a provider whose credential yields no token fails the launch naming it instead of the first request that names it. The door also reads what a request needs (an image, tools, extended thinking, the size of the context it re-sends) against what each candidate's family carries, which `adaptive` filters on. The same or a similarly named model offered by several providers and accounts is one family: its canonical key is the model's family and version with any variant after a `+` (`glm-5-1`, `claude-opus-5-5+high`), which `agent-shim model match` lists beside the pairs that are only similar. `model same z/glm-5.1 o/zai-glm-51` asserts that names the normaliser cannot connect are one model, `model distinct` asserts that two alike names are two, and `model family glm-5-1 --mode cheapest --no-vision --context 200000 --name o/odd-name` sets one family's own settings: how a row selecting the whole family chooses among its targets (`ordered` when absent), the names pinned into it, and what every one of its targets can do, which `adaptive` reads instead of a hand-tagged target. A variant never merges into its family by name alone: a member names the variant it means (`family:claude-opus-5-5+high`) and a bare key means the targets without one. These assertions and settings cascade with the rest of the block, accumulating across layers rather than replacing. `doctor` warns when a virtual model lists targets that look like one model, when a `same` assertion contradicts what the names say, and when a `family:` member names a family no offered target belongs to. An `acct:` member is served by relaunch, one login per process: the door leaves a move request for the session's supervisor (a pool launch under supervision) and answers the request saying it is being moved, which resumes under that identity with the picked model; a session nothing supervises is told the command to run instead (`claude @work --resume`). `agent-shim model explain ` shows which member a virtual model's mode picks over the same signals, and why each candidate stands where it does. diff --git a/src/config/schema.ts b/src/config/schema.ts index 98804440..0b3bcebe 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -503,7 +503,7 @@ const FamilySettingsSchema = z.strictObject({ export type FamilySettings = z.infer; /** The global `models` block: the virtual models, the tier pointers, the prices and the visibility filter. */ -const ModelsConfigSchema = z.strictObject({ +export const ModelsConfigSchema = z.strictObject({ /** Virtual models, by name. */ virtual: z.record(z.string().min(1), VirtualModelSchema).optional(), /** Which model ref each Claude tier (`fable`, `opus`, `sonnet`, `haiku`) points at: a concrete target, an account model, a virtual model, or a family. */ diff --git a/src/frontdoor/commands.ts b/src/frontdoor/commands.ts index 2b9d1606..e501b315 100644 --- a/src/frontdoor/commands.ts +++ b/src/frontdoor/commands.ts @@ -65,6 +65,7 @@ import { createRcSelfHostSurface, rcSelfHostFromEnv } from "./rcSelfHost"; import { readRcSelfHostRecord } from "./rcSelfHostMint"; import { realRcWebFetch, rcWebFetchAllowsPrivateFromEnv } from "./rcWebFetch"; import { RC_IDLE_EXPIRY_MS, createRcSessionTracker, observingRoutedRoute } from "./rcSessions"; +import { targetRefs } from "../models/targets"; import { answerRcControlRequest, authenticateRcSessionMcpServer, endRcSession, getRcSessionContextUsage, getRcSessionMcpStatus, getRcSessionUsage, injectRcUserMessage, interruptRcSession, readRcSessionFile, reconnectRcSessionMcpServer, sendRcKeepAlive, setRcSessionModel, setRcSessionPermissionMode, submitRcSessionMcpOAuthCallbackUrl, suggestRcSessionFiles, teleportRcSession, type RcAnswerDecision, type RcContextUsageDetail, type RcPermissionMode, type RcReadFileOptions } from "./rcWrites"; import { RC_STREAM_BACKOFF_MS, createRcEventFanout, createRcStreamHub, type RcStreamHub } from "./rcStream"; import type { RoutedRequest } from "./route"; @@ -72,7 +73,7 @@ import { createFrontDoorServer, listenFrontDoor } from "./server"; import { DOOR_AWAIT_PID_ENV, DOOR_RESPAWN_MIN_SERVING_MS, DOOR_STALL_BOUND_MS, DOOR_WATCHDOG_CHECK_MS, startThreadWatchdog } from "./watchdog"; import { readFrontDoorListeners, realFrontDoorListenerProbes } from "./listenerReadings"; import { collectFrontDoorStatus, formatFrontDoorListeners, formatFrontDoorStatus, headroomSocketTarget } from "./status"; -import { liveSessionTokens, writeFrontDoorSession } from "./state"; +import { liveSessionTokens, writeFrontDoorSession, readFrontDoorSessionModels } from "./state"; import { declareThisProcessTheFrontDoor } from "./selfIdentity"; import { awaitPidExit, runFrontDoorSupervisor, watchedInstallEntry, type FrontDoorSupervisorPorts, type OwnExecutableStat } from "./supervisor"; import { createAccountReader } from "../usage/account"; @@ -212,7 +213,9 @@ function realFrontDoorSupervisorPorts(paths: LayoutPaths): FrontDoorSupervisorPo const resolveRoute = rcObservingResolver( createProviderRouteResolver({ // Read live so a models-block edit reaches the next request without a door restart. - models: () => readModels(paths), + // The block this session launched with when its directory, profile or committed file added definitions of its own; otherwise the global block, read live so an edit applies to the next request with no relaunch. + models: (session) => (session.sessionId === undefined ? undefined : readFrontDoorSessionModels(realFarmFs, paths.frontdoorSessionsDir, session.sessionId)) ?? readModels(paths), + targets: () => targetRefs(paths), fs: realFsPort, providersDir: paths.providersDir, codexPorts, diff --git a/src/frontdoor/ensure.ts b/src/frontdoor/ensure.ts index 1211cbe2..bf0664fd 100644 --- a/src/frontdoor/ensure.ts +++ b/src/frontdoor/ensure.ts @@ -4,7 +4,7 @@ import { CliError } from "../cliError"; import type { LayoutPaths } from "../paths"; import { readStartLock, type HeadroomFs, type HeadroomLock } from "../headroom/state"; import type { ListenerVerdict } from "./probe"; -import { FRONT_DOOR_PROTOCOL, listFrontDoorSessions, readFrontDoorState, removeFrontDoorSession, writeFrontDoorSession } from "./state"; +import { FRONT_DOOR_PROTOCOL, listFrontDoorSessions, readFrontDoorState, recordOf, removeFrontDoorSession, writeFrontDoorSession, type FrontDoorSessionDetails } from "./state"; /** How long one launch waits for the front door. The listener is this same binary binding one loopback port, so a cold start takes a second or two; the bound only bites when something is genuinely broken. */ const FRONTDOOR_START_TIMEOUT_MS = 30_000; @@ -56,6 +56,8 @@ export function ensureFrontDoor(params: { readonly ports: EnsureFrontDoorPorts; /** Replaces a serving door whatever its protocol, once: the restart a binary update asks for, so the new generation serves the sessions already running without waiting for the registry to empty. */ readonly replace?: boolean; + /** What this launch tells the door about itself beyond its token: the id its requests carry in their session header, and the models block it resolved when it added definitions of its own to the global one. Recorded in the launch's registry entry, so it lives and is pruned with the launch. */ + readonly session?: FrontDoorSessionDetails; }): { readonly port: number; readonly connectPort: number; readonly token: string } { const { paths, ports } = params; const deadline = ports.now() + FRONTDOOR_START_TIMEOUT_MS; @@ -107,7 +109,7 @@ export function ensureFrontDoor(params: { if (supervisorPid === params.launcherPid && params.replace !== true && ports.isFrontDoorSupervisor(supervisorPid)) { // This process is the serving door, which the process's own command line confirms: an equal pid alone proves nothing, since a pid recorded by a door that exited uncleanly can be reused by an ordinary launcher while another user holds the recorded port. A launch planned inside it (the usage probe, the window opener, a job) has nothing to authenticate, and probing itself would block the very event loop that has to answer the probe, stalling every session on the door for the whole wait. It registers the launch like any other and returns its own addresses. const token = randomUUID(); - writeFrontDoorSession(ports.fs, paths.frontdoorSessionsDir, { pid: params.launcherPid, startedAt: ports.now(), token, supervisorPid }); + writeFrontDoorSession(ports.fs, paths.frontdoorSessionsDir, { pid: params.launcherPid, startedAt: ports.now(), token, supervisorPid, ...recordOf(params.session) }); return { port: state.port, connectPort: state.connectPort, token }; } const verdict = verifyWithRetry(state.port); @@ -123,7 +125,7 @@ export function ensureFrontDoor(params: { if (verdict.ok) { // The token is generated here, per launch: the registry entry is what the door's listeners check requests against, and a fresh launch invalidates nothing (its entry is added alongside the live ones). const token = randomUUID(); - writeFrontDoorSession(ports.fs, paths.frontdoorSessionsDir, { pid: params.launcherPid, startedAt: ports.now(), token, supervisorPid }); + writeFrontDoorSession(ports.fs, paths.frontdoorSessionsDir, { pid: params.launcherPid, startedAt: ports.now(), token, supervisorPid, ...recordOf(params.session) }); return { port: state.port, connectPort: state.connectPort, token }; } lastRejection = `the listener on 127.0.0.1:${String(state.port)} (recorded for pid ${String(supervisorPid)}) failed authentication: ${verdict.reason}`; diff --git a/src/frontdoor/providerRoute.test.ts b/src/frontdoor/providerRoute.test.ts index 9b5fbaa4..5c0b144a 100644 --- a/src/frontdoor/providerRoute.test.ts +++ b/src/frontdoor/providerRoute.test.ts @@ -759,3 +759,56 @@ describe("routing by name from a provider session", () => { } }); }); + +describe("a family and the capabilities it carries", () => { + const files: Record = { + [`${PROVIDERS_DIR}/z.json`]: { displayName: "Z", baseUrl: "https://z.example/api", credential: { sources: [{ literal: "z-token" }] } }, + [`${PROVIDERS_DIR}/s.json`]: { displayName: "S", baseUrl: "https://s.example/api", credential: { sources: [{ literal: "s-token" }] } }, + [`${PROVIDERS_DIR}/m.json`]: { displayName: "M", baseUrl: "https://m.example/api", credential: { sources: [{ literal: "m-token" }] } }, + }; + const targets = ["z/glm-5.1", "s/hf:zai-org/GLM-5.1", "m/MiniMax-M3"]; + const spentZ: UsageSnapshot = { schemaVersion: 1, identity: "work", updatedAt: new Date(0).toISOString(), providers: { z: { lastRequestAt: new Date(0).toISOString(), lastStatus: 200, rateLimit: { observedAt: new Date(0).toISOString(), headers: {}, unified: { sevenDay: { utilization: 1, resetsAt: new Date(DAY_RESET_MS).toISOString(), status: "rejected" } } } } } }; + + const resolverOf = (models: ModelsConfig, snapshot?: UsageSnapshot): ReturnType => + createProviderRouteResolver({ fs: fakeFs(files), providersDir: PROVIDERS_DIR, codexPorts: codexPorts(), directPort: () => OWN_PORT, env: {}, credentials: fakeCredentials(), models: () => models, targets: () => targets, ...(snapshot === undefined ? {} : { usageSnapshotOf: () => snapshot }) }); + + /** 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 }, + }); + + /** The provider and model name the resolution handed on, read from the replayed body. */ + const servedBy = async (resolution: Awaited>>): Promise<{ readonly route: string; readonly model: string }> => { + if (!resolution.ok) { + throw new Error(`refused: ${resolution.message}`); + } + const chunks: Buffer[] = []; + for await (const chunk of resolution.request?.body ?? []) { + chunks.push(Buffer.from(chunk as Uint8Array)); + } + return { route: resolution.route.name, model: (JSON.parse(Buffer.concat(chunks).toString("utf8")) as { model: string }).model }; + }; + + it("serves a request naming a family from the first of its targets that stands, under that target's own model name", async () => { + expect(await servedBy(await resolverOf({})(asked("family:glm-5-1")))).toEqual({ route: "http:z", model: "glm-5.1" }); + expect(await servedBy(await resolverOf({}, spentZ)(asked("family:glm-5-1")))).toEqual({ route: "http:s", model: "hf:zai-org/GLM-5.1" }); + }); + + it("expands a family member of a virtual model to the family's targets", async () => { + const models: ModelsConfig = { virtual: { across: { mode: "ordered", members: ["family:glm-5-1", "m/MiniMax-M3"] } } }; + expect(await servedBy(await resolverOf(models, spentZ)(asked("across")))).toEqual({ route: "http:s", model: "hf:zai-org/GLM-5.1" }); + }); + + it("chooses a family row's target by the mode the family's settings name", async () => { + const models: ModelsConfig = { families: { "glm-5-1": { mode: "most-allowance" } } }; + expect((await servedBy(await resolverOf(models, spentZ)(asked("family:glm-5-1")))).route).toBe("http:s"); + }); + + it("rules out, for a request carrying an image, a target whose family says it takes none", async () => { + const models: ModelsConfig = { virtual: { smart: { mode: "adaptive", members: ["z/glm-5.1", "m/MiniMax-M3"] } }, families: { "glm-5-1": { vision: false } } }; + expect((await servedBy(await resolverOf(models)(asked("smart", true)))).route).toBe("http:m"); + expect((await servedBy(await resolverOf(models)(asked("smart", false)))).route).toBe("http:z"); + }); +}); + diff --git a/src/frontdoor/providerRoute.ts b/src/frontdoor/providerRoute.ts index a42f337e..fa598fcb 100644 --- a/src/frontdoor/providerRoute.ts +++ b/src/frontdoor/providerRoute.ts @@ -18,7 +18,8 @@ import { evaluateWhen, referencesFact, type ConditionContext } from "../resolve/ import { quotaFactsOf } from "../usage/quotaFacts"; import { expandMembers } from "../modelsStore"; import { candidateOf } from "../selection/candidates"; -import { selectMember } from "../selection/engine"; +import { selectMember, type RequestNeeds } from "../selection/engine"; +import { FAMILY_PREFIX, buildFamilies, expandFamilyMembers, familyCapabilities, familyMode, familyNamed, familyOfRef } from "../models/families"; import { withoutContextMarker } from "../models/identity"; import type { TargetObservation } from "../selection/signals"; import type { UsageSnapshot } from "../usage/schema"; @@ -53,17 +54,34 @@ interface RoutedModel { readonly ref: string; } -/** Resolves a routing session's model name to its target: a qualified name names its provider directly, a virtual model chooses among its members through its selection mode (quota windows from the session's snapshot, latency and success from the observed targets, prices from the models block), and a bare unknown name has no target. */ +/** What `resolveRoutedModel` reads from the door: the session's models block, the quota snapshot of its identity, what each target's recent requests observed, and the targets the providers offer (which families are formed from). */ +interface RoutedModelDeps { + readonly models?: (session: Readonly<{ readonly sessionId?: string }>) => ModelsConfig | undefined; + readonly usageSnapshotOf?: (identity: string | undefined) => UsageSnapshot | undefined; + readonly observations?: (target: string) => TargetObservation | undefined; + readonly targets?: () => readonly string[]; +} + +/** How many characters of a request body are taken to be one token when the size of the context a switch would re-send is estimated: the common rule of thumb for English text and code, an estimate and never a measurement. */ +const CHARS_PER_TOKEN = 4; + +/** What a request asks of the model that serves it, from the facts a whole-body scan found, for the modes that filter before they rank. */ +function needsOf(scan: Readonly<{ readonly hasImage?: boolean; readonly toolsPresent?: boolean; readonly thinking?: boolean; readonly text?: string }>): RequestNeeds { + return { hasImage: scan.hasImage === true, hasTools: scan.toolsPresent === true, thinking: scan.thinking === true, contextTokens: Math.ceil((scan.text?.length ?? 0) / CHARS_PER_TOKEN) }; +} + +/** Resolves a routing session's model name to its target: a qualified name names its provider directly, a virtual model or a family (`family:`) chooses among its members through its selection mode (quota windows from the session's snapshot, latency and success from the observed targets, prices from the models block, what the request needs against what each member's family carries), and a bare unknown name has no target. */ function resolveRoutedModel( model: string, providerName: string | undefined, session: Readonly<{ readonly identity?: string; readonly sessionId?: string }>, includeUnqualified: boolean, - deps: Readonly<{ readonly models?: () => ModelsConfig | undefined; readonly usageSnapshotOf?: (identity: string | undefined) => UsageSnapshot | undefined; readonly observations?: (target: string) => TargetObservation | undefined }>, + deps: Readonly, memory: Readonly>, rotations: Readonly>, + needs: RequestNeeds | undefined, ): RoutedModel | undefined { - const models = deps.models?.(); + const models = deps.models?.(session); // The wire name a provider expects carries no context marker: Claude Code strips it from the names it sends, and a member ref may name its model with it (`z/glm-5.3[1m]`), so the target the resolver writes has it stripped too or the provider sees a model it does not know. const qualified = model.includes("/") && !model.startsWith("acct:") ? model : undefined; if (qualified !== undefined) { @@ -71,29 +89,34 @@ function resolveRoutedModel( const named = rest.join("/"); return provider === undefined || named === "" ? undefined : { provider, model: withoutContextMarker(named), ref: qualified }; } - const virtual = models?.virtual?.[model]; - if (virtual === undefined || !includeUnqualified) { + const isFamily = model.startsWith(FAMILY_PREFIX); + const virtual = isFamily ? undefined : models?.virtual?.[model]; + if ((virtual === undefined && !isFamily) || !includeUnqualified) { return undefined; } - const members = expandMembers(model, models).filter((member) => !member.startsWith("acct:") && !member.startsWith("family:")); + const declared = isFamily ? [model] : expandMembers(model, models); + // Families are formed from the targets the providers offer, which is read only when a member names a family or the block holds family settings the candidates' capabilities are read from. + const families = isFamily || declared.some((member) => member.startsWith(FAMILY_PREFIX)) || models?.families !== undefined ? buildFamilies(deps.targets?.() ?? [], models) : []; + const mode = virtual?.mode ?? familyMode(familyNamed(families, model)?.settings ?? {}); + const members = expandFamilyMembers(declared, families).filter((member) => !member.startsWith("acct:")); if (members.length === 0) { return undefined; } const snapshot = deps.usageSnapshotOf?.(session.identity); const current = memory.get(session.sessionId ?? ""); - const candidates = members.map((ref, order) => { + const evaluated = members.map((ref, order) => { const [provider] = ref.split("/"); const facts = quotaFactsOf(snapshot?.providers[provider ?? ""]?.rateLimit?.unified, Date.now()); - return candidateOf(ref, order, { + const candidate = candidateOf(ref, order, { quota: facts, ...(deps.observations?.(ref) === undefined ? {} : { observation: deps.observations(ref) }), ...(models?.prices?.[ref] === undefined ? {} : { price: models.prices[ref] }), }, ref === current); + return { candidate, capabilities: familyCapabilities(familyOfRef(families, ref)?.settings ?? {}) }; }); - const evaluated = candidates.map((candidate) => ({ candidate, capabilities: { vision: true, tools: true, thinking: true } })); const rotation = rotations.get(model) ?? 0; rotations.set(model, rotation + 1); - const selection = selectMember(virtual.mode, evaluated, { rotation }); + const selection = selectMember(mode, evaluated, { rotation, ...(needs === undefined ? {} : { needs }) }); if (selection.ref === undefined) { return undefined; } @@ -119,8 +142,10 @@ export function createProviderRouteResolver(deps: { /** Reads provider files, the same filesystem port the launcher uses. */ readonly fs: FsPort; readonly providersDir: string; - /** The models block the router resolves names against, read live so edits take effect without a relaunch. */ - readonly models?: () => ModelsConfig | undefined; + /** The models block the router resolves a session's names against: the block the session launched with when it carried definitions of its own, else the global block read live so edits take effect without a relaunch. */ + readonly models?: (session: Readonly<{ readonly sessionId?: string }>) => ModelsConfig | undefined; + /** Every target the providers offer, as `/` refs: what families are formed from. Absent when nothing offers the door a catalogue. */ + readonly targets?: () => readonly string[]; /** The ports the codex translation route needs, already wired to the real auth store and upstream fetch. */ readonly codexPorts: Omit; /** @@ -313,6 +338,8 @@ export function createProviderRouteResolver(deps: { scopedRequest === request ? resolution : { ok: true, route: servingRewritten(resolution.route, scopedRequest) }; let current = scopedRequest; 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; const visited = new Set(); for (;;) { const path = new URL(current.url, "http://127.0.0.1").pathname; @@ -336,7 +363,7 @@ export function createProviderRouteResolver(deps: { isRouter = false; } // A router resolves each request's model name through the models block to a target provider, whose own kind handling and credential then serve it: the same one-scan, one-rewrite shape the `routes` list below uses. A session that asked to route by name gets the same resolution on the provider it launched on: a qualified model name leaves that provider for the one that serves it. A provider-less (OAuth) session gets it too, but qualified names only: its bare names are Claude Code's own and still go to Anthropic, and the Remote Control family of paths is never a provider's to serve. - const routesByName = (isRouter || current.session.routeByName) && !isRcSelfHostPath(path); + const routesByName = !nameResolved && (isRouter || current.session.routeByName) && !isRcSelfHostPath(path); if (routesByName) { // Whole-body, because a resolved name is served under its target's own model name: the qualified or virtual name the session sent is rewritten in the one copy of the body this scan holds, exactly as a `routes` entry that names a model is. const scan = await scanRequestHead(current, true); @@ -351,7 +378,7 @@ export function createProviderRouteResolver(deps: { } replay = scan.replayed; // An `acct:` member is served by relaunch, never by a route: one login per process. The door asks the session's supervisor to move it (when one is watching) and answers the request with what is happening, so the turn ends cleanly rather than hanging or 502-ing. - const account = scan.model === undefined ? undefined : resolveAccountTarget(scan.model, deps.models?.()); + const account = scan.model === undefined ? undefined : resolveAccountTarget(scan.model, deps.models?.(current.session)); if (account !== undefined) { const sessionId = current.session.sessionId ?? ""; const moved = sessionId !== "" && (deps.accountMoves?.(sessionId, account.identity, account.model) ?? false); @@ -363,7 +390,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, isRouter || providerName !== undefined, deps, routingMemory, rotations); + const routed = scan.model === undefined ? undefined : resolveRoutedModel(scan.model, providerName, current.session, isRouter || providerName !== undefined, deps, routingMemory, rotations, needsOf(scan)); const requested = scan.model; if (routed !== undefined) { routingMemory.set(current.session.sessionId ?? "", routed.ref); @@ -384,6 +411,7 @@ export function createProviderRouteResolver(deps: { const query = current.url.includes("?") ? current.url.slice(current.url.indexOf("?")) : ""; const carried = replay; current = { ...current, url: `${PROVIDER_PATH_PREFIX}${encodeURIComponent(routed.provider)}${rest}${query}`, body: carried.body, headers: carried.headers }; + nameResolved = true; continue; } // Same provider, renamed model: serve the rewritten body here, on the provider the session is already on. diff --git a/src/frontdoor/realFrontDoorPort.ts b/src/frontdoor/realFrontDoorPort.ts index 2667b620..d33be906 100644 --- a/src/frontdoor/realFrontDoorPort.ts +++ b/src/frontdoor/realFrontDoorPort.ts @@ -49,8 +49,8 @@ export function realRestartFrontDoor(paths: LayoutPaths, spawnDaemon: DaemonSpaw /** The real `FrontDoorPort` for one launch: `ensure` runs the lock-and-poll coordination, authenticates the listener and registers this launcher; `release` removes its registration. */ export function realFrontDoorPort(paths: LayoutPaths, spawnDaemon: DaemonSpawner): FrontDoorPort { return { - ensure: (inheritedExtraCaCerts) => { - const up = ensureFrontDoor({ paths, launcherPid: process.pid, ports: realEnsurePorts(paths, spawnDaemon) }); + ensure: (inheritedExtraCaCerts, session) => { + const up = ensureFrontDoor({ paths, launcherPid: process.pid, ports: realEnsurePorts(paths, spawnDaemon), ...(session === undefined ? {} : { session }) }); const trust = resolveTrustBundle({ caCertFile: paths.frontdoorCaCertFile, bundlesDir: paths.frontdoorCaBundlesDir, variable: "NODE_EXTRA_CA_CERTS", inherited: inheritedExtraCaCerts, fs: realTrustBundleFs }); return { port: up.port, diff --git a/src/frontdoor/state.test.ts b/src/frontdoor/state.test.ts index 2f36c3eb..65577227 100644 --- a/src/frontdoor/state.test.ts +++ b/src/frontdoor/state.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { HeadroomFs } from "../headroom/state"; import { buildLayoutPaths } from "../paths"; import { createFakeFarmFs } from "../test-helpers"; -import { listFrontDoorDirectRecords, listFrontDoorSessions, liveSessionTokens, MAX_TCP_PORT, pruneMalformedFrontDoorDirectFiles, pruneDeadFrontDoorSessions, readFrontDoorState, removeFrontDoorSession, writeFrontDoorSession, writeFrontDoorState } from "./state"; +import { listFrontDoorDirectRecords, listFrontDoorSessions, liveSessionTokens, MAX_TCP_PORT, pruneMalformedFrontDoorDirectFiles, pruneDeadFrontDoorSessions, readFrontDoorSessionModels, readFrontDoorState, recordOf, removeFrontDoorSession, writeFrontDoorSession, writeFrontDoorState } from "./state"; const paths = buildLayoutPaths("/home/testuser/.agent-shim"); @@ -46,6 +46,17 @@ describe("front-door session registry", () => { expect([...liveSessionTokens(fs, paths.frontdoorSessionsDir)]).toEqual(["live-token"]); }); + it("finds the models block a launch resolved by the session id its requests carry, and loses it with the launch", () => { + const fs = createFakeFarmFs({}); + writeFrontDoorSession(fs, paths.frontdoorSessionsDir, { pid: LIVE, startedAt: 0, token: "a", ...recordOf({ sessionId: "s-live", models: { virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } } }) }); + writeFrontDoorSession(fs, paths.frontdoorSessionsDir, { pid: DEAD, startedAt: 0, token: "b", ...recordOf({ sessionId: "s-plain" }) }); + expect(readFrontDoorSessionModels(fs, paths.frontdoorSessionsDir, "s-live")).toEqual({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }); + expect(readFrontDoorSessionModels(fs, paths.frontdoorSessionsDir, "s-plain")).toBeUndefined(); + expect(readFrontDoorSessionModels(fs, paths.frontdoorSessionsDir, "unknown")).toBeUndefined(); + pruneDeadFrontDoorSessions(fs, paths.frontdoorSessionsDir, (pid) => pid === DEAD); + expect(readFrontDoorSessionModels(fs, paths.frontdoorSessionsDir, "s-live")).toBeUndefined(); + }); + it("removes one launcher's record idempotently", () => { const fs = createFakeFarmFs({}); writeFrontDoorSession(fs, paths.frontdoorSessionsDir, { pid: LIVE, startedAt: 0, token: "t" }); diff --git a/src/frontdoor/state.ts b/src/frontdoor/state.ts index 3096a9ea..2dcf1e63 100644 --- a/src/frontdoor/state.ts +++ b/src/frontdoor/state.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { z } from "zod"; +import { ModelsConfigSchema, type ModelsConfig } from "../config/schema"; import type { HeadroomFs } from "../headroom/state"; /** @@ -17,9 +18,9 @@ import type { HeadroomFs } from "../headroom/state"; export const FRONT_DOOR_PROTOCOL = 4; /** - * 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. `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. + * 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. */ -const FrontDoorSessionSchema = z.strictObject({ pid: z.number().int().positive(), startedAt: z.number(), token: z.string().min(1), supervisorPid: z.number().int().positive().optional() }); +const FrontDoorSessionSchema = z.strictObject({ pid: z.number().int().positive(), startedAt: z.number(), token: z.string().min(1), supervisorPid: z.number().int().positive().optional(), sessionId: z.string().min(1).optional(), models: ModelsConfigSchema.optional() }); export type FrontDoorSession = z.infer; /** @@ -202,6 +203,17 @@ export function pruneDeadFrontDoorDirectRecords(fs: HeadroomFs, recordsDir: stri } } +/** What a launch records about itself in its registry entry beyond the token: the id its requests carry and, when it resolved one that differs from the global block, its models block. */ +export interface FrontDoorSessionDetails { + readonly sessionId: string; + readonly models?: ModelsConfig; +} + +/** The fields of a registry entry that `details` contributes, none when the launch told the door nothing. */ +export function recordOf(details: FrontDoorSessionDetails | undefined): Pick | Record { + return details === undefined ? {} : { sessionId: details.sessionId, ...(details.models === undefined ? {} : { models: details.models }) }; +} + /** * Writes one launch's session record: the registry entry that both keeps the door from idling out and holds the token that launch's requests must present. The token is a bearer capability, so the directory is created owner-only (0700) and the record written owner-only (0600): on a multi-user host another local account must be able neither to list nor to read it, since presenting it to a listener would spend the victim's provider credential or Codex login. */ @@ -271,3 +283,21 @@ export function liveSessionTokens(fs: HeadroomFs, sessionsDir: string): Readonly } return tokens; } + +/** The models block a launch resolved for itself, found by the session id its requests carry, or undefined when no live launch carries one by that id or the launch added nothing to the global block. Read from the registry the launch wrote, so it lives and is pruned with the launch. */ +export function readFrontDoorSessionModels(fs: HeadroomFs, sessionsDir: string, sessionId: string): ModelsConfig | undefined { + for (const name of fs.readdir(sessionsDir)) { + if (!name.endsWith(".json")) { + continue; + } + try { + const parsed = FrontDoorSessionSchema.safeParse(JSON.parse(fs.readFileUtf8(path.join(sessionsDir, name)) ?? "")); + if (parsed.success && parsed.data.sessionId === sessionId) { + return parsed.data.models; + } + } catch { + continue; + } + } + return undefined; +} diff --git a/src/launcher.models.test.ts b/src/launcher.models.test.ts new file mode 100644 index 00000000..9823cd8f --- /dev/null +++ b/src/launcher.models.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { FAKE_HOME, createFakeFarmFs, discovered, fakeCredentials, fakeFarm, fakeFrontDoorPort, fakeFs, fakeLog, fakeProc, fakeSpawn, paths, runAndCaptureExit, spawnedEnv } from "./test-helpers"; + +describe("the models block a launch resolves", () => { + const providers = { [`${FAKE_HOME}/.agent-shim/providers/z.json`]: { displayName: "GLM", baseUrl: "https://api.z.ai/api/anthropic", credential: { sources: [{ env: "Z_API_TOKEN" }] }, env: { ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" } } }; + /** A farm whose cascade is what the real loader assembles: the global config's block as the shallowest layer, then the committed file's block when there is one. */ + const withCascade = (fs: ReturnType, globalModels: unknown, committed: unknown): ReturnType => ({ + ...fakeFarm(fs), + loadCascade: () => ({ + home: FAKE_HOME, + loadProfile: () => undefined, + globalConfig: { config: { models: globalModels as never }, filepath: `${FAKE_HOME}/.agent-shim/config.json` }, + levels: committed === undefined ? [] : [{ dir: `${FAKE_HOME}/work`, portable: { config: { models: committed as never }, filepath: `${FAKE_HOME}/work/.agent-shim.json` } }], + }), + }); + const launch = (models: unknown, farmModels: unknown): { readonly spawn: ReturnType; readonly frontdoor: ReturnType } => { + const spawn = fakeSpawn(); + const frontdoor = fakeFrontDoorPort(); + const fs = createFakeFarmFs({}); + runAndCaptureExit({ + paths, + fs: fakeFs({ ...providers, [`${FAKE_HOME}/.agent-shim/config.json`]: { models } }), + farm: withCascade(fs, models, farmModels), + spawn, + proc: fakeProc({ Z_API_TOKEN: "tok-z" }, ["--route-by-name", "--provider", "z"]), + log: fakeLog(), + resolveClaudeBinary: () => discovered, + frontdoor, + credentials: fakeCredentials(), + }); + return { spawn, frontdoor }; + }; + const globalOnly = { virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }; + + it("carries definitions a committed file adds into the /model rows, the tier setting and the door's record of the session", () => { + const { spawn, frontdoor } = launch(globalOnly, { virtual: { committed: { mode: "ordered", members: ["z/glm-5.3"] } }, tiers: { opus: "committed" } }); + const args = spawn.spawnSync.mock.calls[0]?.[1] ?? []; + const settings = JSON.parse(args[args.indexOf("--settings") + 1] ?? "{}") as { modelPicker: { options: { model: string }[] } }; + expect(settings.modelPicker.options.map((row) => row.model)).toContain("committed"); + expect(settings.modelPicker.options.map((row) => row.model)).toContain("fast"); + expect(spawnedEnv(spawn).ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("committed"); + const session = frontdoor.sessions()[0]; + expect(session?.models).toEqual({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] }, committed: { mode: "ordered", members: ["z/glm-5.3"] } }, tiers: { opus: "committed" } }); + expect(spawnedEnv(spawn).ANTHROPIC_CUSTOM_HEADERS).toContain(`x-agent-shim-session: ${session?.sessionId ?? "missing"}`); + }); + + it("leaves the door to follow the global block live when no deeper layer adds anything", () => { + const { frontdoor } = launch(globalOnly, undefined); + expect(frontdoor.sessions()[0]?.models).toBeUndefined(); + expect(frontdoor.sessions()[0]?.sessionId).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("tells the door a deeper layer removed everything as an empty block rather than leaving it on the global one", () => { + const { frontdoor } = launch(globalOnly, { virtual: { fast: null } }); + expect(frontdoor.sessions()[0]?.models).toEqual({}); + }); +}); diff --git a/src/launcher.ts b/src/launcher.ts index 014aa9df..9397b3fc 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; import path from "node:path"; import packageJson from "../package.json"; @@ -18,9 +19,8 @@ import type { Candidate } from "./usage/pick"; import { decisionRows, farmRow, noteRow, pinnedVersionRow, routingRows } from "./launcher/launchReport"; import { modelPickerSettings, modelPickerSettingsFromRows, routedModelRows } from "./launcher/modelPicker"; import { catalogueCachePath, parseCatalogueCache } from "./providerCatalogue"; -import { readModels } from "./modelsStore"; import { listProviders } from "./providersStore"; -import type { Provider } from "./config/schema"; +import type { ModelsConfig, Provider } from "./config/schema"; import { resolvePoolLaunch } from "./launcher/pool"; import { resumedContextTokens } from "./usage/resumeContext"; import { IdentityLockBusyError } from "./launcher/lock"; @@ -40,6 +40,7 @@ import { readUsageSnapshot, UsageSnapshotError } from "./usage/read"; import type { ClaudeBinaryResolver } from "./versionDiscovery"; import { resolveClaudeVersion, type PinnedClaudeVersion } from "./launcher/claudeVersion"; import { runLaunchUpdateCheck, type UpdateLaunchPort } from "./update/launchHook"; +import { targetRefs } from "./models/targets"; /** * Everything the farm resync needs that the launcher itself has no way to produce: a real filesystem, a real clock, the working directory, and a way to load the cascade for it. @@ -262,6 +263,10 @@ interface DecidedLaunch { /** What a person should know about this launch that is not one of its decisions: a setting that did not apply, and why. */ readonly notes: readonly string[]; readonly flags: ResolvedLaunchFlags; + /** The models block this launch resolves: the global block with every deeper layer (profile, directory rule, committed file) applied, which is what the picker rows, the credential check and the door's resolution for this session are taken from. */ + readonly models: ModelsConfig | undefined; + /** The global models block alone, which a session that adds no definitions of its own leaves the door to follow live. */ + readonly globalModels: ModelsConfig | undefined; readonly bin: string; readonly args: readonly string[]; readonly decision: LaunchDecision; @@ -451,10 +456,12 @@ function decideLaunch(params: PrepareLaunchParams, readOnly: boolean): DecidedLa // Launch flags come from the cascade's `launch` block: the same flattening of the same assembled cascade the farm resync computes, so a decision made before the resync reports what the resync will carry. Escape-hatch and bare launches have no farm to resync, but launch flags are agent-shim's own behaviour, not the farm's: a global `launch.headroom` (or any other launch setting) must still apply when CLAUDE_CONFIG_DIR was already set or no identity resolved. This reads and flattens the cascade and writes nothing. const launchCascade = farmContext?.cascade ?? (params.farm === undefined ? undefined : params.farm.loadCascade(configProfileDecision.name, cliOverride)); let cascadeLaunch: LaunchFlags | undefined; + let cascadeModels: ModelsConfig | undefined; if (launchCascade !== undefined && params.farm !== undefined) { const assembled = assembleCascade(launchCascade); const flattened = flattenLayers(assembled.layers, { home: params.farm.home }); cascadeLaunch = flattened.launch; + cascadeModels = flattened.models; // A launch that resyncs a farm reports these through the resync. One with no farm (the escape hatch, or no identity resolved) and a read-only decision resync nothing, so they are reported here rather than leaving a layer partly ignored without a word. A read-only decision has no way to refuse, so it reports an error as a warning. if (readOnly || farmContext === undefined) { reportDiagnostics(log, [...assembled.diagnostics, ...flattened.diagnostics], { errorsAsWarnings: readOnly }); @@ -492,8 +499,11 @@ function decideLaunch(params: PrepareLaunchParams, readOnly: boolean): DecidedLa const catalogue = selectedProvider === undefined ? undefined : parseCatalogueCache(params.fs.readFileUtf8(catalogueCachePath(paths, selectedProvider.name))); // A session that routes by model name (the flag, or a router provider) lists the whole catalogue instead: its virtual models first, then every provider's models qualified. const routesByName = flags.routeByName || (selectedProvider !== undefined && isRouterProvider(selectedProvider.definition)); + // The cascade's block when a cascade was read (a launch with a farm), else the global block alone; read through the launcher's own config port so a test sees its own filesystem. + const globalModels = loadConfigFile(paths.globalConfigFile, GlobalConfigSchema, params.fs.readConfigFile)?.config.models; + const models = launchCascade !== undefined && params.farm !== undefined ? cascadeModels : globalModels; const pickerSettings = routesByName - ? modelPickerSettingsFromRows(routedModelRows(readModels(paths) ?? {}, listProviders(paths).map((entry): readonly [string, Provider] => [entry.name, entry.provider])), selectedProvider !== undefined) + ? modelPickerSettingsFromRows(routedModelRows(models ?? {}, listProviders(paths).map((entry): readonly [string, Provider] => [entry.name, entry.provider])), selectedProvider !== undefined) : selectedProvider === undefined ? undefined : modelPickerSettings(selectedProvider.definition, catalogue?.models); @@ -537,6 +547,8 @@ function decideLaunch(params: PrepareLaunchParams, readOnly: boolean): DecidedLa identityCredential, pinnedVersion, flags, + models, + globalModels, bin: discovered.path, args, decision, @@ -621,10 +633,10 @@ 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. Family and account members stay the door's to resolve (a family's providers depend on live catalogues; an account member relaunches rather than dialling out). A read-only resolution never reaches this code, so it still resolves no credential. + // 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) { - const check = resolveRoutedProviderCredentials({ paths, port: params.fs, env, ...(params.credentials === undefined ? {} : { credentials: params.credentials }), models: loadConfigFile(paths.globalConfigFile, GlobalConfigSchema, params.fs.readConfigFile)?.config.models }); + 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); proc.exit(check.status); @@ -638,7 +650,11 @@ export function prepareLaunch(params: PrepareLaunchParams): LaunchPlan { log.error("agent-shim: this launch routes through the front-door daemon, but this launcher has no front-door port wired; refusing to launch without it."); proc.exit(1); } - const frontDoor = frontDoorEngaged && frontDoorPort !== undefined ? frontDoorPort.ensure(env.NODE_EXTRA_CA_CERTS) : undefined; + // The id this launch's requests carry in their session header, and the models block the door resolves them against: the global block live when this launch adds nothing to it, else the block this launch resolved (a deeper layer's definitions the door cannot read from the global file). An absent resolved block against a present global one means a deeper layer removed everything, which the door is told as an empty block. + const sessionId = params.supervised?.sessionId ?? randomUUID(); + const resolvedModels = decided.models ?? (decided.globalModels === undefined ? undefined : {}); + const sessionModels = isDeepStrictEqual(resolvedModels, decided.globalModels) ? undefined : resolvedModels; + const frontDoor = frontDoorEngaged && frontDoorPort !== undefined ? frontDoorPort.ensure(env.NODE_EXTRA_CA_CERTS, { sessionId, ...(sessionModels === undefined ? {} : { models: sessionModels }) }) : undefined; if (frontDoor?.trustWarning !== undefined) { log.warn(frontDoor.trustWarning); } @@ -654,7 +670,6 @@ export function prepareLaunch(params: PrepareLaunchParams): LaunchPlan { rows.push(...routingRows({ frontDoor, headroom, provider: resolvedProvider !== undefined })); const frontDoorRelease = frontDoor === undefined ? undefined : frontDoorPort?.release; - const sessionId = params.supervised?.sessionId ?? randomUUID(); const finalEnv = buildEnv({ baseEnv: env, configDirEscapeHatch: configDirEscapeHatchApplies, @@ -667,6 +682,7 @@ export function prepareLaunch(params: PrepareLaunchParams): LaunchPlan { sessionId, remoteControl: resolvedFlags.remoteControl, routeByName: routesByName, + ...(decided.models?.tiers === undefined ? {} : { tierModels: decided.models.tiers }), }); // Every daemon session registration this launch holds, released when the child exits. diff --git a/src/launcher/flags.test.ts b/src/launcher/flags.test.ts index 7da11efc..9c12705b 100644 --- a/src/launcher/flags.test.ts +++ b/src/launcher/flags.test.ts @@ -211,6 +211,26 @@ describe("buildArgv", () => { 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" }, + baseEnv: { ...baseEnv, ANTHROPIC_DEFAULT_SONNET_MODEL: "kept", ANTHROPIC_DEFAULT_OPUS_MODEL: "replaced" }, + configDirEscapeHatch: true, + identitiesDir: "/home/testuser/.agent-shim/identities", + }); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("fast"); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("family:glm-5-1"); + 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" }, + baseEnv: { ...baseEnv, ANTHROPIC_DEFAULT_OPUS_MODEL: "kept" }, + configDirEscapeHatch: true, + identitiesDir: "/home/testuser/.agent-shim/identities", + }); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("kept"); + }); + it("leaves the environment unchanged when the CLAUDE_CONFIG_DIR escape hatch applied", () => { const env = buildEnv({ sessionId: "session-test", remoteControl: false, routeByName: false, baseEnv: { ...baseEnv, CLAUDE_CONFIG_DIR: "/somewhere/else" }, diff --git a/src/launcher/flags.ts b/src/launcher/flags.ts index 68be020e..8d40025c 100644 --- a/src/launcher/flags.ts +++ b/src/launcher/flags.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { parseEnvBool } from "../cli/envBool"; import { UsageError } from "../cliError"; import { parseDuration } from "../resolve/conditions"; -import type { LaunchFlags, Provider } from "../config/schema"; +import { CLAUDE_TIERS, type ClaudeTier, type LaunchFlags, type Provider } from "../config/schema"; import { credentialVariables, type ResolvedCredential } from "../credential"; import { mergeAnthropicCustomHeaders } from "../headroom/headers"; import { connectProxyUrl } from "../frontdoor/capability"; @@ -176,6 +176,8 @@ export interface BuildEnvParams { 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>>; } /** @@ -210,6 +212,16 @@ export function buildEnv(params: BuildEnvParams): Record { expect(rows[1]).toMatchObject({ label: "z / glm-5.3", description: "z: Serves the opus tier" }); }); + it("lists one row per family offered by more than one target between the virtual models and the qualified rows, naming every target in its description", () => { + const two: readonly (readonly [string, Provider])[] = [ + ["z", http({ ANTHROPIC_DEFAULT_HAIKU_MODEL: "glm-5.1" })], + ["s", http({ ANTHROPIC_DEFAULT_SONNET_MODEL: "hf:zai-org/GLM-5.1" })], + ]; + const rows = routedModelRows({ virtual: { fast: { mode: "ordered", members: ["z/glm-5.1"] } }, families: { "glm-5-1": { mode: "cheapest" } } }, two); + expect(rows.map((row) => row.model)).toEqual(["fast", "family:glm-5-1", "z/glm-5.1", "s/hf:zai-org/GLM-5.1"]); + expect(rows[1]).toMatchObject({ label: "glm-5-1", description: "Family (cheapest): z/glm-5.1, s/hf:zai-org/GLM-5.1", behavesAs: "claude-haiku-5-5" }); + }); + + it("lists no row for a family one target offers, and honours hide for a family row", () => { + const one: readonly (readonly [string, Provider])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.3" })]]; + expect(routedModelRows({}, one).map((row) => row.model)).toEqual(["z/glm-5.3"]); + const two: readonly (readonly [string, Provider])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" })], ["s", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "hf:zai-org/GLM-5.1" })]]; + expect(routedModelRows({ hide: ["family:glm-5-1"] }, two).map((row) => row.model)).not.toContain("family:glm-5-1"); + }); + + it("keeps a virtual model's row when no member names a tier, handled as a sonnet, and one over a family takes its targets' handling", () => { + const offered: readonly (readonly [string, Provider])[] = [["z", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "glm-5.1" })], ["s", http({ ANTHROPIC_DEFAULT_OPUS_MODEL: "hf:zai-org/GLM-5.1" })]]; + const rows = routedModelRows({ virtual: { unknown: { mode: "ordered", members: ["ghost/x"] }, across: { mode: "ordered", members: ["family:glm-5-1"] } } }, offered); + expect(rows.find((row) => row.model === "unknown")?.behavesAs).toBe("claude-sonnet-5-5"); + expect(rows.find((row) => row.model === "across")?.behavesAs).toBe("claude-opus-5-5"); + }); + it("hides what the block hides and, when show is set, lists only what it names", () => { expect(routedModelRows({ hide: ["z/glm-5.3"] }, providers).map((row) => row.model)).not.toContain("z/glm-5.3"); expect(routedModelRows({ show: ["fast"] , virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } }, providers).map((row) => row.model)).toEqual(["fast"]); diff --git a/src/launcher/modelPicker.ts b/src/launcher/modelPicker.ts index f8cecaf4..414299bb 100644 --- a/src/launcher/modelPicker.ts +++ b/src/launcher/modelPicker.ts @@ -3,6 +3,8 @@ import { resolveCodexConfig } from "../codex/translate"; import { filterCatalogue, type CatalogueModel } from "../providerCatalogue"; import { CLAUDE_TIERS, type ClaudeTier, type ModelsConfig, type PickerModel, type Provider } from "../config/schema"; import { CONTEXT_MARKER_1M, withoutContextMarker } from "../models/identity"; +import { FAMILY_PREFIX, accountRefsOf, buildFamilies, expandFamilyMembers, familyMode, sharedFamilies } from "../models/families"; +import { expandMembers } from "../modelsStore"; type Tier = ClaudeTier; @@ -151,37 +153,37 @@ 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 every provider's models grouped under the provider's name, honouring the block's `hide` and `show` lists. A virtual model's row borrows the handling of its first member's tier when that member names a tier-shaped target; a provider-qualified row borrows the tier of the env that names it when one does. + * The rows a session that routes by model name offers: its virtual models first (the mode in the description), then one row per model family offered by more than one target (every provider and account offering it in the description; selecting it lets the family's mode choose among them), then every provider's models grouped under the provider's name, honouring the block's `hide` and `show` lists. A virtual or family row borrows the handling of the first target it can serve from that has a row of its own, since the provider's own tier table says how that model is handled; one with no such target is handled as a sonnet. */ export function routedModelRows(models: ModelsConfig, providers: readonly (readonly [string, Provider])[]): readonly ModelPickerRow[] { const hidden = new Set(models.hide ?? []); const shown = models.show === undefined ? undefined : new Set(models.show); const visible = (model: string): boolean => (shown === undefined ? !hidden.has(model) : shown.has(model)); - const tierOf = (model: string): ClaudeTier | undefined => { - for (const [, provider] of providers) { - if (provider.kind !== undefined && provider.kind !== "http") { - continue; - } - // Only an `ANTHROPIC_DEFAULT__MODEL` key places a model in a tier: `ANTHROPIC_MODEL` names the provider's default rather than a tier and usually holds the same value, so a plain value match would shadow the tier the row needs. - const match = Object.entries(provider.env ?? {}).find(([key, value]) => value === model && key.startsWith("ANTHROPIC_DEFAULT_") && key.endsWith("_MODEL")); - if (match !== undefined) { - const tier = match[0].replace("ANTHROPIC_DEFAULT_", "").replace("_MODEL", "").toLowerCase(); - return tier === "fable" || tier === "opus" || tier === "sonnet" || tier === "haiku" ? tier : undefined; - } - } - return undefined; - }; + const concrete = providers.flatMap(([name, provider]) => providerModelRows(provider).map((row) => ({ ...row, model: `${name}/${row.model}`, label: `${name} / ${row.label}`, description: `${name}: ${row.description}` }))); + const concreteByRef = new Map(concrete.map((row) => [row.model, row])); + const families = buildFamilies([...concrete.map((row) => row.model), ...accountRefsOf(models)], models); + const handlingOf = (refs: readonly string[]): string => refs.map((ref) => concreteByRef.get(ref)?.behavesAs).find((behavesAs) => behavesAs !== undefined) ?? TIER_BEHAVES_AS.sonnet; const virtual = Object.entries(models.virtual ?? {}).flatMap(([name, definition]) => { if (!visible(name)) { return []; } - const first = definition.members.find((member) => member.includes("/")); - const tier = first === undefined ? undefined : tierOf(first.split("/").slice(1).join("/")); - const behavesAs = tier === undefined ? undefined : TIER_BEHAVES_AS[tier]; - return behavesAs === undefined ? [] : [{ model: name, label: name, description: `Virtual (${definition.mode})`, behavesAs }]; + const targets = expandFamilyMembers(safeExpand(name, models), families); + return [{ model: name, label: name, description: `Virtual (${definition.mode})`, behavesAs: handlingOf(targets) }]; }); - const concrete = providers.flatMap(([name, provider]) => providerModelRows(provider).map((row) => ({ ...row, model: `${name}/${row.model}`, label: `${name} / ${row.label}`, description: `${name}: ${row.description}` }))); - return [...virtual, ...concrete.filter((row) => visible(row.model))]; + 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))]; +} + +/** The members of a virtual model with nested ones followed, or just its own members when the nesting cannot be followed (a cycle or a missing model, which `doctor` reports): a picker still lists what it can. */ +function safeExpand(name: string, models: ModelsConfig): readonly string[] { + try { + return expandMembers(name, models); + } catch { + return models.virtual?.[name]?.members ?? []; + } } /** diff --git a/src/launcher/ports.ts b/src/launcher/ports.ts index 7403c5a3..e321c364 100644 --- a/src/launcher/ports.ts +++ b/src/launcher/ports.ts @@ -1,4 +1,5 @@ import type { ConfigFileReader } from "../config/load"; +import type { FrontDoorSessionDetails } from "../frontdoor/state"; /** * Filesystem operations the launcher needs, injected so no test in this project ever touches a real filesystem — consistent with the resolver core's own injected-facts approach in `src/resolve/types.ts`. @@ -156,8 +157,8 @@ export interface HeadroomPort { * Everything the launcher needs from the front-door daemon, injected like `HeadroomPort`: `ensure` brings the door up (spawning its detached supervisor when nothing authenticated is serving), registers this launch in its session registry and returns both listeners' loopback ports plus the CA bundle the child must trust; `release` removes that registration when the spawned `claude` exits. */ export interface FrontDoorPort { - /** `inheritedExtraCaCerts` is the parent environment's own `NODE_EXTRA_CA_CERTS`, which the returned trust bundle keeps (see `resolveTrustBundle`). */ - readonly ensure: (inheritedExtraCaCerts: string | undefined) => FrontDoorUp; + /** `inheritedExtraCaCerts` is the parent environment's own `NODE_EXTRA_CA_CERTS`, which the returned trust bundle keeps (see `resolveTrustBundle`). `session` is what the launch tells the door about itself: its session id and the models block it resolved. */ + readonly ensure: (inheritedExtraCaCerts: string | undefined, session?: FrontDoorSessionDetails) => FrontDoorUp; readonly release: () => void; } diff --git a/src/models.test.ts b/src/models.test.ts index 9d1b7247..1bced3a0 100644 --- a/src/models.test.ts +++ b/src/models.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { describeUnroutedMembers, matchModels } from "./models"; +import { buildFamilies } from "./models/families"; import type { ProviderModel } from "./models/targets"; const of = (provider: string, model: string): ProviderModel => ({ provider, model }); @@ -41,14 +42,16 @@ describe("matchModels", () => { }); describe("describeUnroutedMembers", () => { - it("says an account member is served by relaunching under its identity, and a family is not yet a candidate", () => { - expect(describeUnroutedMembers(["acct:work/opus", "family:glm-5", "z/glm-5.3"])).toEqual([ + const families = buildFamilies(["z/glm-5.1", "s/hf:zai-org/GLM-5.1"], undefined); + + it("says an account member is served by relaunching under its identity, and a family no target belongs to has nothing to choose among", () => { + expect(describeUnroutedMembers(["acct:work/opus", "family:ghost-9", "z/glm-5.3"], families)).toEqual([ 'acct:work/opus is served by relaunching the session under identity "work", so no quota signal ranks it', - "family:glm-5 is resolved from the live catalogues, which the door does not read yet, so it is not a candidate", + "no target the providers offer belongs to family:ghost-9, so it has nothing to choose among", ]); }); - it("says nothing of a member the door chooses by signal", () => { - expect(describeUnroutedMembers(["z/glm-5.3", "m/MiniMax-M3"])).toEqual([]); + it("says nothing of a member the door chooses by signal, nor of a family some target belongs to", () => { + expect(describeUnroutedMembers(["z/glm-5.3", "m/MiniMax-M3", "family:glm-5-1"], families)).toEqual([]); }); }); diff --git a/src/models.ts b/src/models.ts index 898bca8d..78f6b620 100644 --- a/src/models.ts +++ b/src/models.ts @@ -2,10 +2,10 @@ import type { Command } from "commander"; import { printJson, reportMutation, withExamples, confirmRemoval, type CommandDeps } from "./cli/commandDeps"; import { collectRepeated } from "./cli/parsers"; -import { providerModels, type ProviderModel } from "./models/targets"; +import { providerModels, targetRefs, type ProviderModel } from "./models/targets"; import { CLAUDE_TIERS, SELECTION_MODES, type ModelsConfig, type SelectionModeName } from "./config/schema"; import { UsageError } from "./cliError"; -import { buildFamilies } from "./models/families"; +import { FAMILY_PREFIX, buildFamilies, expandFamilyMembers, familyCapabilities, familyMode, familyNamed, familyOfRef, type Family } from "./models/families"; import { relateModels } from "./models/identity"; import { addDistinctPair, addSameGroup, expandMembers, readModels, removeAssertionsNaming, removeVirtualModel, setFamily, setPrice, setTier, setVirtualModel, VirtualModelNotFoundError } from "./modelsStore"; import { realFarmFs } from "./realPorts"; @@ -42,37 +42,40 @@ function providerQuotaOf(paths: LayoutPaths): (provider: string) => Readonly { if (member.startsWith("acct:")) { const [identity] = member.slice("acct:".length).split("/"); return [`${member} is served by relaunching the session under identity "${identity ?? ""}", so no quota signal ranks it`]; } - return member.startsWith("family:") ? [`${member} is resolved from the live catalogues, which the door does not read yet, so it is not a candidate`] : []; + return member.startsWith(FAMILY_PREFIX) && familyNamed(families, member) === undefined ? [`no target the providers offer belongs to ${member}, so it has nothing to choose among`] : []; }); } -/** Explains one virtual model's choice: the member its mode picks over the live signals, and why each candidate stands where it does. */ +/** Explains one virtual model's (or family row's) choice: the member its mode picks over the live signals, and why each candidate stands where it does. */ function explainVirtualModel(paths: LayoutPaths, name: string): void { const models = readModels(paths); - const definition = models?.virtual?.[name]; - if (definition === undefined) { + const virtual = models?.virtual?.[name]; + const families = buildFamilies(targetRefs(paths), models); + const isFamily = name.startsWith(FAMILY_PREFIX); + if (virtual === undefined && !isFamily) { throw new UsageError(`No virtual model named "${name}".`); } + const mode = virtual?.mode ?? familyMode(familyNamed(families, name)?.settings ?? {}); const quotaOf = providerQuotaOf(paths); const observations = createTargetObservationStore(realFarmFs, paths); - const members = expandMembers(name, models); - const candidates = members - .filter((member) => !member.startsWith("acct:") && !member.startsWith("family:")) + const members = isFamily ? [name] : expandMembers(name, models); + const candidates = expandFamilyMembers(members, families) + .filter((member) => !member.startsWith("acct:")) .map((ref, order) => { const [provider] = ref.split("/"); return candidateOf(ref, order, { quota: quotaOf(provider ?? ""), ...(observations.observation(ref) === undefined ? {} : { observation: observations.observation(ref) }), ...(models?.prices?.[ref] === undefined ? {} : { price: models.prices[ref] }) }, false); }); - const evaluated = candidates.map((candidate) => ({ candidate, capabilities: { vision: true, tools: true, thinking: true } })); - const selection = selectMember(definition.mode, evaluated, {}); - const unrouted = describeUnroutedMembers(members); - console.log(`${name} (${definition.mode}) chooses ${selection.ref ?? (candidates.length === 0 ? "nothing by quota" : "nothing")}:`); + const evaluated = candidates.map((candidate) => ({ candidate, capabilities: familyCapabilities(familyOfRef(families, candidate.ref)?.settings ?? {}) })); + const selection = selectMember(mode, evaluated, {}); + const unrouted = describeUnroutedMembers(members, families); + console.log(`${name} (${mode}) chooses ${selection.ref ?? (candidates.length === 0 ? "nothing by quota" : "nothing")}:`); // With no routable candidate the engine's own reasons describe an empty set (it reads as every member being spent), so only what the members are is said. for (const line of candidates.length === 0 ? unrouted : [...selection.reasons, ...unrouted]) { console.log(` ${line}`); @@ -115,7 +118,7 @@ export function registerModelsCommand(program: Command, deps: CommandDeps): void withExamples( modelsCommand .command("explain ") - .description("Show which member a virtual model's mode picks over the live signals, and why each candidate stands where it does.") + .description("Show which member a virtual model's (or a family row's) mode picks over the live signals, and why each candidate stands where it does.") .action((name: string) => { explainVirtualModel(paths, name); }), diff --git a/src/models/families.test.ts b/src/models/families.test.ts index 0c354c76..304f19f6 100644 --- a/src/models/families.test.ts +++ b/src/models/families.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildFamilies, familyCapabilities, familyKeyOf, familyMode, familyNamed, sharedFamilies } from "./families"; +import { buildFamilies, expandFamilyMembers, familyCapabilities, familyKeyOf, familyMode, familyNamed, familyOfRef, sharedFamilies } from "./families"; describe("familyKeyOf", () => { it("gives equal keys to one model under different spellings, and the variant a separate suffix", () => { @@ -60,3 +60,20 @@ describe("the family helpers", () => { expect(familyMode({})).toBe("ordered"); }); }); + +describe("expandFamilyMembers", () => { + const families = buildFamilies(["z/glm-5.1", "s/hf:zai-org/GLM-5.1", "m/MiniMax-M3"], undefined); + + it("replaces a family member with its targets in place, without repeating one the list already holds", () => { + expect(expandFamilyMembers(["m/MiniMax-M3", "family:glm-5-1", "z/glm-5.1"], families)).toEqual(["m/MiniMax-M3", "z/glm-5.1", "s/hf:zai-org/GLM-5.1"]); + }); + + it("expands a family no target belongs to to nothing, and leaves plain members alone", () => { + expect(expandFamilyMembers(["family:ghost-9", "m/MiniMax-M3"], families)).toEqual(["m/MiniMax-M3"]); + }); + + it("finds the family a target belongs to", () => { + expect(familyOfRef(families, "s/hf:zai-org/GLM-5.1")?.key).toBe("glm-5-1"); + expect(familyOfRef(families, "nowhere/x")).toBeUndefined(); + }); +}); diff --git a/src/models/families.ts b/src/models/families.ts index d95d99e9..63167e4f 100644 --- a/src/models/families.ts +++ b/src/models/families.ts @@ -97,3 +97,29 @@ export function familyMode(settings: FamilySettings): SelectionModeName { export function sharedFamilies(families: readonly Family[]): readonly Family[] { return families.filter((family) => family.refs.length > 1); } + +/** + * A member list with every `family:` member replaced by the targets of the family it names, in place and without repeating a ref the list already holds. A family no target belongs to expands to nothing: the doctor reports it, and a request is served from what remains. + */ +export function expandFamilyMembers(members: readonly string[], families: readonly Family[]): readonly string[] { + const expanded: string[] = []; + for (const member of members) { + for (const ref of member.startsWith(FAMILY_PREFIX) ? (familyNamed(families, member)?.refs ?? []) : [member]) { + if (!expanded.includes(ref)) { + expanded.push(ref); + } + } + } + return expanded; +} + +/** The family a target belongs to, for reading the capabilities its family carries. */ +export function familyOfRef(families: readonly Family[], ref: string): Family | undefined { + return families.find((family) => family.refs.includes(ref)); +} + +/** The account refs the user's assertions and family settings name, since nothing else says which models an account offers: the only account targets a family can hold. */ +export function accountRefsOf(models: ModelsConfig | undefined): readonly string[] { + const named = [...(models?.same ?? []).flat(), ...Object.values(models?.families ?? {}).flatMap((settings) => settings.names ?? [])]; + return [...new Set(named.filter((ref) => ref.startsWith("acct:")))]; +} diff --git a/src/providers.test.ts b/src/providers.test.ts index 347be411..2531b7f6 100644 --- a/src/providers.test.ts +++ b/src/providers.test.ts @@ -446,6 +446,19 @@ describe("providers", () => { expect(result).toEqual({ ok: true, checked: ["m", "z"] }); }); + it("checks a family member through every provider offering a target of the family, and skips one no target belongs to", () => { + addProvider(paths, "z", zInput); + addProvider(paths, "m", { displayName: "MiniMax", baseUrl: "https://api.minimax.example", sources: [{ env: "M_API_TOKEN" }] }); + const models = { virtual: { across: { mode: "ordered" as const, members: ["family:glm-5-1", "family:ghost-9"] } } }; + const targets = (): readonly string[] => ["z/glm-5.1", "m/hf:zai-org/GLM-5.1", "m/MiniMax-M3"]; + expect(resolveRoutedProviderCredentials({ paths, port: tempFsPort(), env: { Z_API_TOKEN: "tok-z", M_API_TOKEN: "tok-m" }, credentials: fakeCredentials(), models, targets })).toEqual({ ok: true, checked: ["m", "z"] }); + const missing = resolveRoutedProviderCredentials({ paths, port: tempFsPort(), env: { Z_API_TOKEN: "tok-z" }, credentials: fakeCredentials(), models, targets }); + expect(missing.ok).toBe(false); + if (!missing.ok) { + expect(missing.message).toContain("provider m has no usable credential"); + } + }); + it("names every failure at once: an unknown provider and a credential that yields no token", () => { addProvider(paths, "z", zInput); const models = { virtual: { a: { mode: "ordered" as const, members: ["z/glm-5.3", "ghost/x"] } } }; diff --git a/src/providersStore.ts b/src/providersStore.ts index 38782b8e..38219d54 100644 --- a/src/providersStore.ts +++ b/src/providersStore.ts @@ -27,6 +27,7 @@ import type { ResolvedProvider } from "./launcher/flags"; import type { FsPort } from "./launcher/ports"; import { assembleCascade, type CascadeInput } from "./resolve/walk"; import type { LayoutPaths } from "./paths"; +import { FAMILY_PREFIX, buildFamilies, expandFamilyMembers, type Family } from "./models/families"; /** Raised by any operation that requires a provider to already exist, when it does not. */ export class ProviderNotFoundError extends CliError { @@ -566,10 +567,12 @@ export interface ResolveRoutedProviderCredentialsParams { readonly credentials?: CredentialPort; /** The models block whose provider targets to check. Absent when none is defined, which checks nothing. */ readonly models: ModelsConfig | undefined; + /** Every target the providers offer, as `/` refs: what a `family:` member expands to. Read only when a member names a family. */ + readonly targets?: () => readonly string[]; } /** - * Checks that every provider the models block can route a request to is usable now, by resolving each named provider's credential the same way `resolveProvider` resolves a selected one. Only provider members are checked: a `family:` member's providers depend on live catalogues the door holds, an `acct:` member relaunches under its identity rather than dialling out, and a nested virtual model's own members are already in the set this walk collects. Pure over its injected ports, so the launcher's tests exercise refusals without touching a real providers directory or secret store. + * Checks that every provider the models block can route a request to is usable now, by resolving each named provider's credential the same way `resolveProvider` resolves a selected one. Provider members are checked, and a `family:` member is checked through every provider that offers a target of the family; an `acct:` member relaunches under its identity rather than dialling out, and a nested virtual model's own members are already in the set this walk collects. Pure over its injected ports, so the launcher's tests exercise refusals without touching a real providers directory or secret store. */ export function resolveRoutedProviderCredentials(params: ResolveRoutedProviderCredentialsParams): RoutedProviderCredentials { const { models } = params; @@ -578,8 +581,15 @@ export function resolveRoutedProviderCredentials(params: ResolveRoutedProviderCr } const virtualNames = new Set(Object.keys(models.virtual ?? {})); const providerNames = new Set(); + const families = (): readonly Family[] => buildFamilies(params.targets?.() ?? [], models); const collect = (ref: string): void => { - if (ref.startsWith("acct:") || ref.startsWith("family:") || virtualNames.has(ref)) { + if (ref.startsWith(FAMILY_PREFIX)) { + for (const target of expandFamilyMembers([ref], families())) { + collect(target); + } + return; + } + if (ref.startsWith("acct:") || virtualNames.has(ref)) { return; } const [provider] = ref.split("/", 1); diff --git a/src/resolve/walk.test.ts b/src/resolve/walk.test.ts index d6ff9d06..bc855997 100644 --- a/src/resolve/walk.test.ts +++ b/src/resolve/walk.test.ts @@ -52,6 +52,17 @@ describe("walkDirectoryAncestors", () => { }); }); +describe("assembleCascade global models", () => { + it("carries the global config's models block as the shallowest layer, so deeper layers add to or remove from it", () => { + const assembled = assembleCascade({ + home: FAKE_HOME, + globalConfig: { config: { models: { virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } } }, filepath: "/cfg/config.json" }, + loadProfile: () => undefined, + }); + expect(assembled.layers[0]).toMatchObject({ kind: "global-config", models: { virtual: { fast: { mode: "ordered", members: ["z/glm-5.3"] } } } }); + }); +}); + describe("assembleCascade layer ordering", () => { const load = loader({ base: {}, work: { extends: ["base"] }, strict: {} }); diff --git a/src/resolve/walk.ts b/src/resolve/walk.ts index 60a4bfac..123c50e3 100644 --- a/src/resolve/walk.ts +++ b/src/resolve/walk.ts @@ -115,6 +115,7 @@ export function assembleCascade(input: CascadeInput): AssembledCascade { ...(config.entries === undefined ? {} : { entries: config.entries }), ...(entryOrder === undefined ? {} : { entryOrder }), ...(config.launch === undefined ? {} : { launch: config.launch }), + ...(config.models === undefined ? {} : { models: config.models }), }); nextId += 1; } diff --git a/src/test-helpers.ts b/src/test-helpers.ts index 61cdc2aa..2c71eb7a 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -20,6 +20,7 @@ import type { CascadeInput } from "./resolve/walk"; import type { DiscoveredClaudeBinary } from "./versionDiscovery"; import type { PipelineDeps } from "./frontdoor/pipeline"; import { AUTH_HEADER } from "./frontdoor/route"; +import type { FrontDoorSessionDetails } from "./frontdoor/state"; /** The shipped classification map, parsed once, for use as the default in tests. */ export const shippedClassification: CategoryClassification = CategoryClassificationSchema.parse(categoriesDefaultJson); @@ -440,17 +441,20 @@ export function fakeFrontDoorPort( port = 4100, connectPort = 4200, trust: { readonly path: string; readonly warning?: string } = { path: FAKE_TRUST_BUNDLE }, -): FrontDoorPort & { readonly ensures: () => number; readonly releases: () => number; readonly inherited: () => readonly (string | undefined)[] } { +): FrontDoorPort & { readonly ensures: () => number; readonly releases: () => number; readonly inherited: () => readonly (string | undefined)[]; readonly sessions: () => readonly (FrontDoorSessionDetails | undefined)[] } { let ensures = 0; let releases = 0; const inherited: (string | undefined)[] = []; + const sessions: (FrontDoorSessionDetails | undefined)[] = []; return { ensures: () => ensures, releases: () => releases, inherited: () => inherited, - ensure: (inheritedExtraCaCerts) => { + sessions: () => sessions, + ensure: (inheritedExtraCaCerts, session) => { ensures += 1; inherited.push(inheritedExtraCaCerts); + sessions.push(session); return { port, connectPort, trustBundlePath: trust.path, ...(trust.warning === undefined ? {} : { trustWarning: trust.warning }), sessionToken: "launch-token-for-tests" }; }, release: () => {