/**
 * # session/harness/roster — the COST-TIER serving roster (cash discipline, ADR-0013)
 *
 * The single source of truth for WHICH lane each model serves from and WHAT that lane costs. Cash
 * discipline is the whole point: gateway calls cost CASH; subscriptions (Claude Code subagents,
 * Codex/ChatGPT) and cloud CREDITS (Bedrock / Vertex / Azure) do NOT. So LARGE runs — the watcher
 * fan-out especially — MUST default to ZERO-CASH lanes, and the cash gateway is the default only for
 * lots of small cheap runs or models that have no credit/free lane at all.
 *
 * This module is data + a router ({@link resolveLane}); it imports NO provider package, so it stays
 * as pure as the deterministic core and can be read by cost-planning code that must not pull the SDK.
 * The wire-touching mapping (a {@link HarnessProvider} per lane) lives in `./ai-sdk-client.ts`; here we
 * only name the lane and its cost. It is ADDITIVE to `scripts/bench/run-grid.ts`'s Bedrock ROSTER —
 * the Claude Bedrock entries below carry the SAME inference-profile ids that grid probes (the credit
 * lane for the Claude grid), reconciled rather than regressed.
 */

/** Which fleet an entry belongs to — the strategist (few, expensive, high-stakes turns) or a watcher
 * (the large fan-out fleet whose cash cost, multiplied across the fleet, is the thing to drive to zero). */
export type Tier = "strategist" | "watcher";

/** A serving lane. `bedrock`/`vertex`/`azure` are cloud CREDITS; `local` (mlx_lm/vLLM/Ollama), `subagent`
 * (Claude Code subscription), and `codex-cli` (ChatGPT subscription) are ZERO-CASH; `gateway` (the Vercel
 * AI Gateway), `openai` (the direct OpenAI API), and `fireworks` (hosted big open weights) are the CASH
 * lanes. See {@link laneProvider} for the lane → provider mapping.
 *
 * The two subscription lanes are NOT the same shape, and the difference is load-bearing:
 *   - **`codex-cli`** is a per-turn SUBPROCESS lane (`codex exec`) — a real {@link LlmClient}. It can author
 *     AND it can watch multi-turn. Free, but slow (a process spawn per turn).
 *   - **`subagent`** is ONE-SHOT. A Claude Code subagent is spawned, works, and returns text; it cannot be
 *     driven turn-by-turn. So it is an AUTHORING lane only ({@link laneCanServeMultiTurn} is false) — it
 *     produces a frozen plan fixture. Multi-turn Claude watching needs an API endpoint (bedrock/gateway).
 *
 * `vertex` vs `vertex-maas`, because the names are close and the auth is NOT: `vertex` is the
 * Gemini/Google-provider lane (API key, `createGoogleGenerativeAI`); `vertex-maas` is Vertex Model
 * Garden **MaaS** — the open frontier weights (DeepSeek V3.2, Qwen3-Next-80B, Kimi K2 Thinking) served
 * on GCP CREDITS over the OpenAI-compatible `locations/global` endpoint with an **ADC bearer** (no API
 * key exists for it). Pinned to location=global: every regional endpoint rejects MaaS slugs
 * (kestrel-k8he, probe wf_e58255fd-e01).
 *
 * `codex-cli`/`subagent` are external CLI lanes, not SDK providers — the SDK-backed lanes are
 * `bedrock`→bedrock, `vertex`→google, `vertex-maas`→vertex-maas, `local`→local, `gateway`→gateway,
 * `openai`→openai, `azure`→azure, `fireworks`→fireworks. */
export type Lane =
  | "bedrock"
  | "gateway"
  | "local"
  | "vertex"
  | "vertex-maas"
  | "codex-cli"
  | "subagent"
  | "openai"
  | "azure"
  | "fireworks";

/** What a lane costs to run. `free` = subscription or local hardware (no marginal cash); `credits` =
 * pre-committed cloud credits (no cash out); `cash` = billed per token (gateway / openai / fireworks). */
export type Cost = "free" | "credits" | "cash";

/** The lanes that bill CASH per token. **This is the guard**: {@link resolveLane} will not put a LARGE
 * run on any of these while the model has a free/credits primary, and `tests/harness.serving-layer.test.ts`
 * asserts that across the WHOLE roster. Adding a cash lane means adding it here — otherwise a large
 * fan-out could default onto it and bill silently, which is exactly the failure this set exists to stop. */
export const CASH_LANES: readonly Lane[] = ["gateway", "openai", "fireworks"];

/** One model's PRIMARY serving policy: the lane it defaults to plus the cost that lane incurs. When the
 * model also has a Vercel-AI-Gateway route, `gatewayModelId` names its provider-prefixed id there —
 * the CASH fallback {@link resolveLane} MAY pick for a SMALL spot-check (never for a large run when a
 * free/credits primary exists). */
export interface RosterEntry {
  readonly label: string;
  readonly tier: Tier;
  /** The model id on the PRIMARY lane (a Bedrock inference-profile id, a Vertex/Gemini id, a local
   * open-weight id, or the CLI's model name). NOT the gateway id — that is `gatewayModelId`. */
  readonly modelId: string;
  /** The primary (zero-cash or credits) lane this model defaults to. */
  readonly lane: Lane;
  /** The cost of the primary lane. In this roster every primary is `free` or `credits` (never `cash`) —
   * the gateway is only ever a small-run fallback via `gatewayModelId`. */
  readonly cost: Cost;
  /** The provider-prefixed model id on the Vercel AI Gateway (CASH), when a gateway route exists — e.g.
   * `anthropic/claude-haiku-4.5`, `openai/gpt-5.6-sol`. Absent ⇒ no gateway fallback for this model.
   * Shorthand for a {@link cashLane} of `"gateway"` — see {@link cashRoute}. */
  readonly gatewayModelId?: string;
  /** WHICH cash lane the small-run fallback rides, when it is not the Vercel gateway. `openai` for a GPT
   * model whose credits lane is Azure; `fireworks` for a big open weight whose free lane is local. */
  readonly cashLane?: Extract<Lane, "gateway" | "openai" | "fireworks">;
  /** The model id ON the cash lane (an OpenAI model id, a Fireworks `accounts/…/models/…` path). Pairs
   * with {@link cashLane}; ignored when {@link gatewayModelId} is used instead. */
  readonly cashModelId?: string;
  readonly note?: string;
}

/** The CASH route for an entry, normalized across the two spellings (`gatewayModelId` shorthand, or an
 * explicit `cashLane`+`cashModelId`). `undefined` ⇒ this model has NO cash fallback at all. Pure. */
export function cashRoute(entry: RosterEntry): { readonly lane: Lane; readonly modelId: string } | undefined {
  if (entry.cashModelId !== undefined) return { lane: entry.cashLane ?? "gateway", modelId: entry.cashModelId };
  if (entry.gatewayModelId !== undefined) return { lane: "gateway", modelId: entry.gatewayModelId };
  return undefined;
}

/**
 * The cost-tier roster. Policy encoded, per model:
 *
 * - **Claude Opus 4.8 / Sonnet 5 / Haiku 4.5** → `bedrock` / `credits` — the DEFAULT for large runs.
 *   Opus/Haiku also carry a gateway id (small-run cash fallback); Sonnet 5 has no gateway route.
 * - **Claude Fable 5** → PRIMARY `subagent` / `free` (Claude Code subscription) because Bedrock REFUSES
 *   Fable (its data-retention posture is `default`, which Bedrock will not serve); the gateway id is a
 *   cash small-run fallback.
 * - **GPT-5.6 Sol** → PRIMARY `codex-cli` / `free` (ChatGPT subscription; an external CLI lane wired in a
 *   follow-up, not an SDK provider); the gateway id is a cash fallback.
 * - **Gemini 3.1 Pro** → PRIMARY `vertex` / `credits` (the existing google provider + GCP); the gateway
 *   id is a cash fallback.
 * - **Open-weight watchers** (Qwen3-Max, DeepSeek V4-Flash, Gemma 4, GLM-5.2, Kimi K2.6) → PRIMARY
 *   `local` / `free` (the zero-cash, CFG-capable lane for the large fleet); the gateway is a cash spot-check.
 */
export const ROSTER: readonly RosterEntry[] = [
  // ── Strategists — Claude on Bedrock (credits), the default for large runs ──────────────────────
  {
    label: "claude-opus-4.8",
    tier: "strategist",
    modelId: "us.anthropic.claude-opus-4-8",
    lane: "bedrock",
    cost: "credits",
    gatewayModelId: "anthropic/claude-opus-4.8",
    note: "DEFAULT large-run lane: Bedrock credits (no cash). Gateway anthropic/claude-opus-4.8 is a small-run cash fallback only.",
  },
  {
    label: "claude-sonnet-5",
    tier: "strategist",
    modelId: "us.anthropic.claude-sonnet-5",
    lane: "bedrock",
    cost: "credits",
    note: "Bedrock credits (no cash). No gateway route wired — Bedrock is the only lane.",
  },
  {
    label: "claude-haiku-4.5",
    tier: "strategist",
    modelId: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
    lane: "bedrock",
    cost: "credits",
    gatewayModelId: "anthropic/claude-haiku-4.5",
    note: "Bedrock credits (no cash). Gateway anthropic/claude-haiku-4.5 is a small-run cash fallback only.",
  },
  {
    label: "claude-fable-5",
    tier: "strategist",
    modelId: "claude-fable-5",
    lane: "subagent",
    cost: "free",
    gatewayModelId: "anthropic/claude-fable-5",
    note: "PRIMARY = Claude Code subagent (subscription, FREE) because Bedrock REFUSES Fable — its data-retention is 'default', which Bedrock will not serve. AUTHORING-ONLY: a subagent is ONE-SHOT, so this lane authors frozen plan fixtures (scripts/bench/author-cells.ts) and CANNOT serve a multi-turn watcher column (laneCanServeMultiTurn === false). Fable 5 is the largest model available to us, so it is the intended BASELINE STRATEGIST whose frozen plan the watcher fleet is asked to manage. To WATCH with Fable you need an API endpoint: gateway anthropic/claude-fable-5 (CASH, small runs only).",
  },
  // ── GPT-5.6 Sol — ChatGPT subscription via Codex CLI (free); gateway cash fallback ─────────────
  {
    label: "gpt-5.6-sol",
    tier: "strategist",
    modelId: "gpt-5.6-sol",
    lane: "codex-cli",
    cost: "free",
    gatewayModelId: "openai/gpt-5.6-sol",
    note: "PRIMARY = Codex CLI on the ChatGPT subscription (FREE) — an external CLI lane, NOT an SDK provider, now WIRED: `codex exec` via src/session/harness/codex-cli-client.ts. Unlike `subagent` this lane IS per-turn, so it serves BOTH as a one-shot strategist author and as a multi-turn watcher — but a subprocess per turn is SLOW (~4s+/call), so keep the fan small. Gateway openai/gpt-5.6-sol is a small-run cash fallback only.",
  },
  // ── Gemini 3.1 Pro — Vertex credits via the existing google provider; gateway cash fallback ────
  {
    label: "gemini-3.1-pro",
    tier: "strategist",
    modelId: "gemini-3.1-pro-preview",
    lane: "vertex",
    cost: "credits",
    gatewayModelId: "google/gemini-3.1-pro-preview",
    note: "PRIMARY = Vertex / GCP credits (via the existing google provider). Gateway google/gemini-3.1-pro-preview is a small-run cash fallback only.",
  },
  // ── Open-weight watchers — LOCAL (free, CFG-capable) for the large fleet; gateway cash spot-check ─
  {
    label: "qwen3-max",
    tier: "watcher",
    modelId: "qwen3-max",
    lane: "local",
    cost: "free",
    gatewayModelId: "alibaba/qwen3-max",
    note: "PRIMARY = local mlx_lm/vLLM/Ollama (FREE, CFG-capable) for the watcher fan-out. Gateway alibaba/qwen3-max (Qwen under alibaba/) is a small-run cash spot-check only.",
  },
  {
    label: "deepseek-v4-flash",
    tier: "watcher",
    modelId: "deepseek-v4-flash",
    lane: "local",
    cost: "free",
    gatewayModelId: "deepseek/deepseek-v4-flash",
    note: "PRIMARY = local (FREE) watcher lane. Gateway deepseek/deepseek-v4-flash is a small-run cash spot-check only.",
  },
  {
    label: "gemma-4",
    tier: "watcher",
    modelId: "gemma-4",
    lane: "local",
    cost: "free",
    note: "PRIMARY = local (FREE) watcher lane. No gateway route wired — local is the only lane.",
  },
  {
    label: "glm-5.2",
    tier: "watcher",
    modelId: "glm-5.2",
    lane: "local",
    cost: "free",
    gatewayModelId: "zai/glm-5.2",
    note: "PRIMARY = local (FREE) watcher lane. Gateway zai/glm-5.2 (GLM under zai/) is a small-run cash spot-check only.",
  },
  {
    label: "kimi-k2.6",
    tier: "watcher",
    modelId: "kimi-k2.6",
    lane: "local",
    cost: "free",
    gatewayModelId: "moonshotai/kimi-k2.6",
    note: "PRIMARY = local (FREE) watcher lane. Gateway moonshotai/kimi-k2.6 is a small-run cash spot-check only.",
  },
  {
    // Kimi K3 (kestrel-2ojt, owner directive 2026-07-17) — the SUCCESSOR seat to kimi-k2.6, kept as a
    // DISTINCT entry (k2.6 stays). Frontier-class open MoE (2.8T total params, ~16/896 experts active, 1M
    // context) that rivals Opus 4.8 — a strategist-candidate, but seated as a `watcher` to mirror its k2.6
    // predecessor; re-tier to `strategist` if a clocked run casts it as a book-level author.
    //
    // LANE REALITY (owner priority Vertex → Azure → Bedrock → OpenRouter, checked 2026-07-17): K3 is NOT yet
    // on Vertex Model Garden (only Kimi K2 Thinking is MaaS there), NOT on Azure AI Foundry (catalog page is
    // an empty stub), NOT on Bedrock (no Kimi at all). The ONLY hosted surface serving K3 today is the CASH
    // tier — Vercel AI Gateway `moonshotai/kimi-k3` ($3/1M in, $15/1M out; the same rate OpenRouter posts),
    // i.e. the owner's OpenRouter fallback. At 2.8T params K3 is NOT realistically servable on the local
    // watcher hardware (same as the 405B/671B "big open weight" entries below whose `local` primary is
    // aspirational): the operative lane today is the gateway cash spot-check. `local` primary keeps the
    // roster's cash-discipline invariant (no cash PRIMARY) and is the target once a hosted-batch or local
    // path exists. NOTE the cost jump: K3 is ~3x k2.6's input and ~3.75x its output ($0.95/$4.00). When
    // Vertex/Azure/Bedrock admit K3, promote the primary to that credits lane (follow-up bead).
    label: "kimi-k3",
    tier: "watcher",
    modelId: "kimi-k3",
    lane: "local",
    cost: "free",
    gatewayModelId: "moonshotai/kimi-k3",
    note: "PRIMARY = local (FREE) — ASPIRATIONAL at 2.8T params (not yet locally servable; same as the 405B/671B big-open-weight entries). Gateway moonshotai/kimi-k3 ($3/1M in, $15/1M out — the OpenRouter-class CASH fallback) is the ONLY lane serving K3 today: Vertex/Azure/Bedrock do not carry it yet (owner priority order, 2026-07-17). ~3x/3.75x the cost of kimi-k2.6.",
  },

  // ── Vertex Model Garden MaaS — GCP CREDITS primary (owner ruling 2026-07-18: GCP credits are the
  // primary driver), OpenAI-compatible + ADC bearer, location=global ONLY (kestrel-k8he) ────────────
  //
  // The three MaaS seats verified serving 200 on the global endpoint (probe wf_e58255fd-e01);
  // deepseek-v3.1/r1 and the llama/glm MaaS slugs 404, and us-central1 rejects EVERYTHING — the client
  // fails loudly on any configured region (the region trap). No cash fallback wired: these models'
  // gateway/Fireworks routes are separate roster seats, and inventing a cross-seat fallback here would
  // blur which lane a result came from. Pricing: Vertex posts per-model MaaS rates; the rows in
  // `scripts/bench/pricing.ts` are ESTIMATE PLACEHOLDERS pending a rate-card pull — flagged there, so a
  // cost claim on this lane is honest about its provenance.
  {
    label: "deepseek-v3.2-maas",
    tier: "watcher",
    modelId: "deepseek-ai/deepseek-v3.2-maas",
    lane: "vertex-maas",
    cost: "credits",
    note: "PRIMARY = Vertex Model Garden MaaS (GCP CREDITS — the primary credits driver, owner 2026-07-18). ADC bearer, location=global ONLY (us-central1 rejects all MaaS slugs). Verified serving 200 (probe wf_e58255fd-e01). Pricing row is an ESTIMATE placeholder — verify the Vertex MaaS rate card before quoting $.",
  },
  {
    label: "qwen3-next-80b-maas",
    tier: "watcher",
    modelId: "qwen/qwen3-next-80b-a3b-instruct-maas",
    lane: "vertex-maas",
    cost: "credits",
    note: "PRIMARY = Vertex Model Garden MaaS (GCP CREDITS). ADC bearer, location=global ONLY. Verified serving 200 (probe wf_e58255fd-e01). Pricing row is an ESTIMATE placeholder — verify the Vertex MaaS rate card before quoting $.",
  },
  {
    label: "kimi-k2-thinking-maas",
    tier: "watcher",
    modelId: "moonshotai/kimi-k2-thinking-maas",
    lane: "vertex-maas",
    cost: "credits",
    note: "PRIMARY = Vertex Model Garden MaaS (GCP CREDITS). ADC bearer, location=global ONLY. Verified serving 200 (probe wf_e58255fd-e01). A THINKING model, but the MaaS OpenAI surface's effort dial is UNVERIFIED — thinkingLevel sends nothing on this lane (see thinkingProviderOptions). Pricing row is an ESTIMATE placeholder.",
  },

  // ── GPT watchers — AZURE credits primary, direct-OpenAI cash spot-check (bead cfg-earn-retest) ────
  //
  // The cash-discipline shape that matters: a GPT watcher's LARGE fan-out rides **Azure** (pre-committed
  // credits, no cash out), and the direct OpenAI API — which bills real cash per token — is reachable ONLY
  // as a SMALL spot-check. `resolveLane` enforces that structurally, and `harness.serving-layer.test.ts`
  // asserts it across the whole roster, so a new GPT entry cannot quietly put a fan-out on a cash lane.
  //
  // `modelId` is the AZURE DEPLOYMENT NAME (on Azure the deployment, not the model, is addressable);
  // `cashModelId` is the OpenAI model id of the nearest equivalent tier. They are NOT the same model
  // generation — pairing them is a deliberate TIER equivalence, mirrored by the pricing assumption in
  // `scripts/bench/pricing.ts` (both flagged there as an ASSUMPTION to verify against the posted rates).
  //
  // Constrained decoding on BOTH lanes is the strict JSON Schema (`watcher.schema.json`), NOT the GBNF —
  // OpenAI/Azure cannot take a CFG. That encoding is LOOSER (see the artifact's `looserThanGbnf`), so a
  // small residual invalid-emission rate on these lanes is expected and is a real datum, not a bug.
  {
    label: "gpt-5.4-mini",
    tier: "watcher",
    modelId: "gpt-5.4-mini", //  the Azure DEPLOYMENT name
    lane: "azure",
    cost: "credits",
    cashLane: "openai",
    cashModelId: "gpt-5-mini",
    note: "PRIMARY = Azure OpenAI (CREDITS — no cash out) for the fan-out. Direct OpenAI gpt-5-mini is a small-run CASH spot-check only. Constrained by strict JSON Schema (no GBNF on this lane).",
  },
  {
    label: "gpt-5.4-nano",
    tier: "watcher",
    modelId: "gpt-5.4-nano", //  the Azure DEPLOYMENT name
    lane: "azure",
    cost: "credits",
    cashLane: "openai",
    cashModelId: "gpt-5-nano",
    note: "PRIMARY = Azure OpenAI (CREDITS). Direct OpenAI gpt-5-nano is a small-run CASH spot-check only. Constrained by strict JSON Schema (no GBNF on this lane).",
  },

  // ── BIG OPEN WEIGHTS — local (free) primary, FIREWORKS cash spot-check ────────────────────────────
  //
  // Fireworks is the reason this lane exists: it is the only HOSTED surface that takes a **GBNF grammar**
  // in `response_format`, so a 405B/671B open model gets the SAME emission floor the local lane gets
  // (bead kestrel-y31) — which is precisely what the #39 EARN re-test needs in order to claim that a
  // remaining α gap is JUDGMENT and not spelling. It bills CASH, so it stays a small-run route: the
  // large fan-out rides `local`.
  //
  // The Fireworks model ids follow `accounts/fireworks/models/<slug>`. The slugs below are UNVERIFIED
  // against the live catalog — confirm with `curl https://api.fireworks.ai/inference/v1/models` (or the
  // model page) before spending. A wrong slug fails LOUDLY at call time (404), never silently.
  {
    label: "deepseek-v3",
    tier: "watcher",
    modelId: "deepseek-v3",
    lane: "local",
    cost: "free",
    cashLane: "fireworks",
    cashModelId: "accounts/fireworks/models/deepseek-v3", // UNVERIFIED slug
    note: "PRIMARY = local (FREE). Fireworks is a small-run CASH spot-check — and the only hosted lane that takes the GBNF. Model slug UNVERIFIED against the live Fireworks catalog.",
  },
  {
    label: "llama-3.1-405b",
    tier: "watcher",
    modelId: "llama-3.1-405b-instruct",
    lane: "local",
    cost: "free",
    cashLane: "fireworks",
    cashModelId: "accounts/fireworks/models/llama-v3p1-405b-instruct", // UNVERIFIED slug
    note: "PRIMARY = local (FREE). Fireworks CASH spot-check under GBNF. Model slug UNVERIFIED.",
  },
  {
    label: "qwen3-235b",
    tier: "watcher",
    modelId: "qwen3-235b-a22b",
    lane: "local",
    cost: "free",
    cashLane: "fireworks",
    cashModelId: "accounts/fireworks/models/qwen3-235b-a22b", // UNVERIFIED slug
    note: "PRIMARY = local (FREE). Fireworks CASH spot-check under GBNF. Model slug UNVERIFIED.",
  },
];

/** How large a run is — the watcher fan-out (many turns × many watchers) is `large` and MUST stay
 * zero-cash; a handful of turns (a spot-check, a one-off strategist probe) is `small`. */
export type RunSize = "small" | "large";

/** Why a lane was chosen — for cash-discipline auditing / logging. The `gateway-*` reasons are kept for
 * the Vercel-gateway route (the leaderboard's existing evidence names them); a non-gateway cash route
 * (openai / fireworks) reports the lane-agnostic `cash-*` forms so a log never claims "gateway" for a
 * call that went to OpenAI. */
export type LaneReason =
  | "primary"
  | "gateway-small-spotcheck"
  | "gateway-no-alternative"
  | "cash-small-spotcheck"
  | "cash-no-alternative";

/** The resolved lane for a run: the concrete lane, its cost, the model id ON that lane (the primary id
 * for a primary resolution, the gateway id for a gateway resolution), and why it was chosen. */
export interface LaneResolution {
  readonly lane: Lane;
  readonly cost: Cost;
  readonly modelId: string;
  readonly reason: LaneReason;
}

/** Is this lane one that bills CASH? The gateway, the direct OpenAI API, and Fireworks. */
export function isCashLane(lane: Lane): boolean {
  return CASH_LANES.includes(lane);
}

/**
 * Route a roster entry to a serving lane under cash discipline.
 *
 * - **LARGE** run → the model's ZERO-CASH primary lane (free/credits). This NEVER routes to a cash lane
 *   when a free/credits lane exists — the core invariant. The only way a large run reaches the gateway is
 *   when the entry has NO zero-cash primary (`cost === "cash"`), which no entry in this roster does.
 * - **SMALL** run → MAY take the gateway (CASH) as a cheap spot-check, but only when the entry actually
 *   has a gateway route (`gatewayModelId`). Otherwise it stays on the zero-cash primary.
 */
export function resolveLane(entry: RosterEntry, opts: { readonly runSize: RunSize }): LaneResolution {
  const primaryIsCash = isCashLane(entry.lane); // true only if a model's ONLY lane bills cash
  const primary: LaneResolution = {
    lane: entry.lane,
    cost: entry.cost,
    modelId: entry.modelId,
    reason: primaryIsCash ? (entry.lane === "gateway" ? "gateway-no-alternative" : "cash-no-alternative") : "primary",
  };

  // A LARGE run must never touch a cash lane while a free/credits lane exists — always the primary.
  // This is the guard that makes the GPT lanes safe: a large fan-out of `gpt-5-mini` resolves to its
  // AZURE (credits) primary, and reaching the CASH OpenAI lane requires an explicit small run.
  if (opts.runSize === "large") return primary;

  // A SMALL run MAY take a cash lane as a spot-check — but only when a cash route exists AND the primary
  // is itself zero-cash (if the primary already billed cash, that's `*-no-alternative`).
  const cash = cashRoute(entry);
  if (cash !== undefined && !primaryIsCash) {
    return {
      lane: cash.lane,
      cost: "cash",
      modelId: cash.modelId,
      reason: cash.lane === "gateway" ? "gateway-small-spotcheck" : "cash-small-spotcheck",
    };
  }
  return primary;
}

/** Look up a roster entry by its exact label (undefined if absent). */
export function rosterEntry(label: string): RosterEntry | undefined {
  return ROSTER.find((e) => e.label === label);
}

// ─────────────────────────────────────────────────────────────────────────────
// Lane → provider resolution (the lanes were DECLARATIVE-ONLY until now)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The {@link HarnessProvider} name a lane is SERVED by — or `null` when the lane has no per-turn client at all.
 *
 * This is the mapping that was missing: `Lane` has always been able to SAY `codex-cli` / `subagent`, but
 * nothing turned that into a client, so every multi-turn run silently fell back to Bedrock (limited credits)
 * or the gateway (cash). The string returned here is exactly a `HarnessProvider`, which
 * `harnessLlmClient()` (`./harness-client.ts`) knows how to construct. Kept as a plain string so this module
 * stays import-free of the provider layer (it must remain readable by cost-planning code that must not pull
 * the SDK).
 *
 * **`subagent` returns `null`, deliberately.** A Claude Code subagent is ONE-SHOT — spawned, it works, it
 * returns text — so it can AUTHOR a plan doc but can never be driven turn-by-turn inside the harness's
 * multi-turn loop. Claude-via-subagent is an authoring lane (`scripts/bench/author-cells.ts`), and multi-turn
 * WATCHING still requires a real API endpoint. A caller that asks this function for a per-turn `subagent`
 * client gets `null` and must fail closed rather than invent one.
 */
export function laneProvider(lane: Lane): string | null {
  switch (lane) {
    case "bedrock":
      return "bedrock";
    case "gateway":
      return "gateway";
    case "local":
      return "local";
    case "vertex":
      return "google";
    case "vertex-maas":
      return "vertex-maas"; // Vertex Model Garden MaaS (CREDITS) — OpenAI-compatible + ADC bearer, location=global only (kestrel-k8he)
    case "codex-cli":
      return "codex-cli"; // the ChatGPT-subscription SUBPROCESS lane (`codex exec`) — FREE, and real
    case "openai":
      return "openai"; // direct OpenAI API (CASH) — SDK-backed, constrained by strict JSON Schema
    case "azure":
      return "azure"; // Azure OpenAI (CREDITS) — the same GPT models on pre-committed credits
    case "fireworks":
      return "fireworks"; // hosted big open weights (CASH) — the only hosted lane that takes a GBNF grammar
    case "subagent":
      return null; // ONE-SHOT authoring only — there is no per-turn subagent client (see doc above)
  }
}

/** Can this lane serve a MULTI-TURN run (a watcher column, a single-model day loop)? True for every lane with
 * a per-turn client; FALSE for `subagent`, which is one-shot authoring only. The honest capability check a
 * runner should make before casting a model as a watcher. */
export function laneCanServeMultiTurn(lane: Lane): boolean {
  return laneProvider(lane) !== null;
}

// ─────────────────────────────────────────────────────────────────────────────
// Fail-closed Bedrock model-id resolution (kestrel-rul.1)
//
// The id MAPPING is owned above — every Bedrock `RosterEntry.modelId` is already the correct
// `us.anthropic.*` inference-profile id (never a bare foundation id, which will not serve on this
// account). What was still missing is the fail-CLOSED guard: a caller that asks for a model the
// roster does not serve on Bedrock must ERROR OUT, never silently proceed on some other model.
// cohort-v0 asked for `claude-sonnet-4-6`, got an invalid identifier, and SILENTLY fell back to
// `sonnet-5` — corrupting that benchmark row's model identity (AGENTS.md fail-closed: an unknown
// series arming a trade is the same class of bug). `resolveBedrockModelId` closes that door.
// ─────────────────────────────────────────────────────────────────────────────

/** Raised when a requested model cannot be resolved to an INVOKABLE Bedrock inference-profile id —
 * an unknown label/id, an empty id, or a model that is listed-but-not-invokable on Bedrock (Fable 5,
 * whose primary lane is `subagent` because Bedrock refuses its `default` data-retention posture).
 * Fail-closed: the caller must surface this, never silently substitute a different model. */
export class ModelResolutionError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ModelResolutionError";
  }
}

/** The Bedrock-invokable model labels (entries whose primary lane is `bedrock`) — the menu named in a
 * fail-closed {@link resolveBedrockModelId} error, so a mis-resolution shows the real alternatives
 * rather than a silent default. */
export function bedrockInvokableLabels(): readonly string[] {
  return ROSTER.filter((e) => e.lane === "bedrock").map((e) => e.label);
}

/**
 * Resolve a requested model to the Bedrock inference-profile id to invoke — FAIL-CLOSED.
 *
 * Accepts either a roster LABEL (`"claude-opus-4.8"`) or an already-resolved Bedrock inference-profile
 * id (`"us.anthropic.claude-opus-4-8"` — an entry's own `modelId`). Returns the `us.anthropic.*`
 * profile id to send to the provider. RAISES {@link ModelResolutionError} — naming the invokable
 * labels — when the request is empty, unknown to the roster, or names a model whose primary lane is
 * NOT Bedrock (e.g. Fable 5, which Bedrock refuses). It NEVER returns a substitute model
 * (kestrel-rul.1): resolution either yields the exact model asked for, or it throws.
 */
export function resolveBedrockModelId(request: string): string {
  const trimmed = request.trim();
  if (trimmed === "") {
    throw new ModelResolutionError(
      `empty model id — cannot resolve to a Bedrock inference profile. Invokable: ${bedrockInvokableLabels().join(", ")}`,
    );
  }
  // Match by label first, then by an already-resolved Bedrock profile id (an entry's own modelId).
  const entry = ROSTER.find((e) => e.label === trimmed) ?? ROSTER.find((e) => e.modelId === trimmed);
  if (entry === undefined) {
    throw new ModelResolutionError(
      `unresolvable model "${request}" — not in the roster; never silently substituted. Invokable on Bedrock: ${bedrockInvokableLabels().join(", ")}`,
    );
  }
  if (entry.lane !== "bedrock") {
    throw new ModelResolutionError(
      `model "${entry.label}" is not invokable on Bedrock (primary lane: ${entry.lane}${entry.note !== undefined ? ` — ${entry.note}` : ""}). Invokable on Bedrock: ${bedrockInvokableLabels().join(", ")}`,
    );
  }
  return entry.modelId;
}
