What changed, and why
The game's four AI layers (plus five supporting calls) ran on a single OpenAI model behind one exported constant. The go-to-market plan's first dispatch calls for a per-layer provider seam, cost telemetry, and a bake-off that picks a winner per layer. This PR builds all three — and then actually runs the bake-off before setting any default.
The result: a routing table where every AI task names its lane and its full parameter payload, a gateway that is the single transport for every structured call (and the single place telemetry is emitted), nine call sites refactored behind it with their prompts, schemas, guards and never-throw envelopes untouched, and an upgraded eval harness whose two-lane comparison produced the verdict the table now encodes. The journey mattered: the first bake-off put Anthropic tiers on eight of ten tasks but lost the prose pairwise 19–0; a prompt-tuning campaign (the chronicle prompt was written for gpt-5.5) won the prose back on Sonnet, and the final state runs all ten tasks on Anthropic tiers — every slot earned in blind pairwise, the winner-per-layer rule applied honestly in both directions.
The routing table
ai-routing.ts is configuration that can 400 in production if it drifts: each Anthropic model family accepts a different parameter set. Haiku rejects effort outright; Sonnet silently defaults effort to HIGH (this game wants reflexes, so the table always pins it); Opus rejects temperature entirely. The table encodes those rules in its shape, the comments carry the bake-off evidence for each slot, and a spec (below) fails on illegal pairings.
The parameter interfaces carry the family rules as doc comments; the chronicler/epilogue slots carry the bake-off verdict inline.
server/utils/ai-routing.ts · 157 lines
⋯ 35 lines hidden (lines 1–35)
⋯ 10 lines hidden (lines 59–68)
⋯ 47 lines hidden (lines 111–157)
The gateway: one transport, one telemetry point
completeStructured() is the only road to a model for single-shot structured calls. It resolves the task's lane, shapes the request per provider, and emits one [ai] line per call — task, lane, model, tokens in/out, latency, ok — which is the cost instrument dispatch 1 requires (suppressed under vitest; the eval harness subscribes via an observer hook to price bake-off lanes).
One shaping decision worth reading: Anthropic requires the first message to be a user turn. Single-shot tasks send the whole built prompt AS the user message (no system param) — equivalent for one-off generation, and it avoids a contentless "Proceed." turn. Conversational tasks (the character layer) keep system + turns.
completeStructured dispatches and emits; viaAnthropic shapes messages, applies the param family, and reads past thinking blocks for the text.
server/utils/ai-gateway.ts · 200 lines
⋯ 107 lines hidden (lines 1–107)
⋯ 30 lines hidden (lines 132–161)
Nine call sites, zero contract changes
Every layer keeps the exact contract it had: build prompt → structured call → parse → guard → never-throw envelope. The diff inside openai.ts (filename kept to avoid churning every reference) is mechanical: the client+create block becomes a completeStructured call. One semantic addition: the Chronicler splits into two tasks at the call site — chronicler mid-run, epilogue for the final telling — so the share artifact can route to a different model than the every-turn rewrite.
The Chronicler: status picks the task; everything below the call is unchanged from before the seam.
server/utils/openai.ts · 463 lines
⋯ 403 lines hidden (lines 1–403)
⋯ 17 lines hidden (lines 447–463)
The suggester is the one non-trivial port: its tool loop (the lookup_figure verification agent) has provider-specific wire shapes, so it gets a second implementation beside the OpenAI one — same system prompt, same tool contract, same final structured schema, with strict: true tools on the Anthropic side.
The Anthropic agent loop: research rounds until the model stops calling tools, then a forced structured final.
server/utils/figure-suggester.ts · 260 lines
⋯ 184 lines hidden (lines 1–184)
⋯ 17 lines hidden (lines 244–260)
Eval harness: dual judges, debiased pairwise
The harness's grading went from one model to two: every yes/no verdict is rendered independently by gpt-5.4 and claude-sonnet-4-6 and counts only when they agree — neither provider grades its own homework. Contested counts are surfaced, not silently dropped. For prose, prefer() runs blind pairwise: each grader judges both orderings, and its vote counts only when it picks the same text in both positions — a position-flipped pick is bias, not signal.
Agreement-gated grading; the consistency rule inside prefer() is the position-bias kill.
evals/harness.ts · 260 lines
⋯ 134 lines hidden (lines 1–134)
⋯ 62 lines hidden (lines 149–210)
⋯ 34 lines hidden (lines 227–260)
The bake-off and its labeled fixtures
bakeoff.eval.ts drives identical fixtures down both lanes (sequentially — REVISIONIST_AI_LANE per pass) and scores per layer: Timeline band ordering plus the neutral-band EV corridor the run economy is tuned around, Judge calibration, Character differential and discipline, Chronicler consistency, then cross-lane blind pairwise on the prose and a cost table from the gateway's telemetry observer. Each run writes a JSON snapshot to evals/results/.
The Judge ladders are the new labeled set: six scenarios, four dispatches each, authored a craft level apart (masterful-ish → sound → vague → reckless). The golden claim is ordinal — grades must descend down each ladder — plus hard fairness floors (a bottom rung must never grade masterful).
Two of the six ladders; each rung is authored to a craft level, and the metric is ordering, not absolute grades.
evals/bakeoff-fixtures.ts · 121 lines
⋯ 21 lines hidden (lines 1–21)
⋯ 71 lines hidden (lines 51–121)
The telemetry collector prices lanes from real usage; the cross-lane block runs the blind pairwise and writes the snapshot.
evals/bakeoff.eval.ts · 372 lines
⋯ 46 lines hidden (lines 1–46)
⋯ 221 lines hidden (lines 74–294)
⋯ 42 lines hidden (lines 331–372)
The prompt-tuning campaign — winning the prose back
The bake-off's one loss didn't stand. The chronicle prompt had been written and iterated against gpt-5.5, so the follow-up campaign rewrote it for Claude (evals/prompt-variants.ts, run by evals/prompt-tune.eval.ts against the FIXED incumbent baseline): V1 sets a per-sentence specificity bar with one neutral exemplar; V2 adds a transmission-chain demand and an outright ban on abstract summary sentences. Model tiers then stepped down with the winning prompt — and the data inverted expectations twice. Fable 5 won first but Sonnet matched it at a quarter of the price; Opus lost at every effort level, including xhigh. The shipped result: the epilogue on Sonnet + V1 (16–3 combined across two dual-graded rounds, 9–0 in the N=12 confirmation), the mid-run telling on Sonnet + V2 (8–5; its higher-N confirmation is the one open item, noted in the verdict doc — cut short when the OpenAI account ran out of quota, which simultaneously demonstrated why the two-lane seam exists).
The production wiring keeps prompts as per-model artifacts: the shared ledger/stance blocks are extracted once, buildChroniclePromptClaude carries the winning voice (V1 register for the epilogue, V2 for mid-run), and callChroniclerAI picks the voice by routed lane — so the OpenAI fallback lever keeps its own tuned prompt.
The Claude voice: V2's harder bar for the every-turn telling, V1's for the epilogue, over the shared data blocks.
server/utils/prompt-builder.ts · 431 lines
⋯ 304 lines hidden (lines 1–304)
⋯ 101 lines hidden (lines 331–431)
The verdict
Mechanics passed on both lanes: 4/4 band ordering, neutral means inside the [4..9] corridor (7.0 vs 8.9), 16/16 anachronism accuracy, identical character differentials. Haiku's judge calibration was perfect — 36/36 ordered pairs, zero hard violations — earning the cheapest model the most latency-critical slot (it runs before every die roll).
The prose initially did not pass: blind pairwise went 19–0 for gpt-5.5 under the shared prompt, with the Anthropic grader concurring (so not sibling bias), and a manual read agreed — the gpt-5.5 tellings carried denser era-correct specificity. That diagnosis became the rewrite brief for the campaign in the previous section, which won the slots back; the table below is the final state.
| Task | Lane | Model |
|---|---|---|
| judge | anthropic | claude-haiku-4-5 |
| character / timeline / archivist / objective | anthropic | claude-sonnet-4-6 |
| archive-lookup / suggester / lifespan | anthropic | claude-haiku-4-5 |
| chronicler / epilogue | anthropic | claude-sonnet-4-6 + the Claude-voice prompt |
Verification
366 unit tests green, nuxt typecheck clean, 12/12 Playwright e2e. New specs: the routing-table legality rules (illegal model/param pairings fail in CI, not as production 400s), an Anthropic-lane pipeline spec mirroring the OpenAI one (same band-clamp → amplify → sign-guard assertions through the new transport, plus message shaping and telemetry assertions), and the two OpenAI-mocked specs pinned to the legacy lane — which doubles as proof the reverse-migration lever works.
Beyond tests: a production build served a real dispatch through the tiered seam (Haiku judge → Sonnet character → Sonnet timeline, telemetry flowing), and the suggester's Haiku agent grounded Ptolemy VIII, Aurelian, and Amr ibn al-As for the library objective — the three historical destroyers of the library, found by the cheap model with tool verification.
The family-rule assertions: Haiku never sees effort, Opus never sees temperature, the epilogue carries adaptive thinking.