diff --git a/packages/host-service/src/trpc/router/usage/history/pricing.test.ts b/packages/host-service/src/trpc/router/usage/history/pricing.test.ts index f3c75bc7a18..229300e4812 100644 --- a/packages/host-service/src/trpc/router/usage/history/pricing.test.ts +++ b/packages/host-service/src/trpc/router/usage/history/pricing.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test } from "bun:test"; -import { matchModelRate } from "./pricing"; +import { cacheSavingsUsd, costUsd, matchModelRate } from "./pricing"; describe("matchModelRate", () => { test("matches vendor-qualified ids from multi-model harnesses", () => { const rate = matchModelRate("opencode", "anthropic/claude-sonnet-5"); expect(rate.approximate).toBe(false); - expect(rate.inputPerM).toBe(3); - expect(rate.outputPerM).toBe(15); + expect(rate.inputPerM).toBe(2); + expect(rate.outputPerM).toBe(10); }); test("unknown models fall back to the cheapest rate, marked approximate", () => { @@ -20,6 +20,39 @@ describe("matchModelRate", () => { expect(rate.inputPerM).toBe(2); }); + test("prices Fable 5.1 and Mythos 5.1 cache reads at their own rate, not the usual 0.1x", () => { + const fable51 = matchModelRate("claude", "claude-fable-5-1"); + const fable5 = matchModelRate("claude", "claude-fable-5"); + expect(fable51).toMatchObject({ + inputPerM: 10, + outputPerM: 50, + cacheReadPerM: 0.25, + approximate: false, + }); + expect(matchModelRate("claude", "claude-mythos-5-1")).toMatchObject({ + cacheReadPerM: 0.25, + approximate: false, + }); + expect(matchModelRate("omp", "anthropic/claude-fable-5-1")).toMatchObject({ + cacheReadPerM: 0.25, + approximate: false, + }); + const cachedMillion = { + uncachedInput: 0, + cachedInput: 1_000_000, + cacheWrite5m: 0, + cacheWrite1h: 0, + output: 0, + }; + expect(costUsd(fable51, cachedMillion)).toBeCloseTo(0.25); + expect(costUsd(fable5, cachedMillion)).toBeCloseTo(1); + expect( + costUsd(matchModelRate("claude", "claude-mythos-5"), cachedMillion), + ).toBeCloseTo(1); + expect(cacheSavingsUsd(fable51, cachedMillion)).toBeCloseTo(9.75); + expect(cacheSavingsUsd(fable5, cachedMillion)).toBeCloseTo(9); + }); + test("uses Gemini Pro long-context tiers above 200k prompt tokens", () => { expect(matchModelRate("agy", "gemini-3.1-pro", 200_000)).toMatchObject({ inputPerM: 2, diff --git a/packages/host-service/src/trpc/router/usage/history/pricing.ts b/packages/host-service/src/trpc/router/usage/history/pricing.ts index 56b0e0eadd2..0ab1bb81d06 100644 --- a/packages/host-service/src/trpc/router/usage/history/pricing.ts +++ b/packages/host-service/src/trpc/router/usage/history/pricing.ts @@ -8,11 +8,16 @@ import type { UsageAgent } from "../types"; * Longest-prefix match on the lowercased model id; unknown models fall back * to the agent's cheapest rate and mark the result approximate. */ -export const PRICING_TABLE_UPDATED = "2026-08-16"; +export const PRICING_TABLE_UPDATED = "2026-09-01"; export interface ModelRate { inputPerM: number; outputPerM: number; + /** + * Cache-read price in USD per million tokens, for models priced off the + * usual `CACHE_READ_MULTIPLIER` share of input. + */ + cacheReadPerM?: number; longContext?: ModelRate; } @@ -22,6 +27,10 @@ export const CACHE_WRITE_5M_MULTIPLIER = 1.25; export const CACHE_WRITE_1H_MULTIPLIER = 2; const CLAUDE_RATES: Record = { + // Fable 5.1 and Mythos 5.1 keep Fable 5's token price but cut cache reads + // to $0.25/M (0.025x input) instead of the usual 0.1x. + "claude-fable-5-1": { inputPerM: 10, outputPerM: 50, cacheReadPerM: 0.25 }, + "claude-mythos-5-1": { inputPerM: 10, outputPerM: 50, cacheReadPerM: 0.25 }, "claude-fable-5": { inputPerM: 10, outputPerM: 50 }, "claude-mythos": { inputPerM: 10, outputPerM: 50 }, "claude-opus-5": { inputPerM: 5, outputPerM: 25 }, @@ -31,17 +40,21 @@ const CLAUDE_RATES: Record = { "claude-opus-4-5": { inputPerM: 5, outputPerM: 25 }, // Opus 4.0/4.1 predate the price cut. "claude-opus-4": { inputPerM: 15, outputPerM: 75 }, - "claude-sonnet-5": { inputPerM: 3, outputPerM: 15 }, + // Sonnet 5's launch price is permanent: the $3/$15 increase scheduled for + // 2026-09-01 was cancelled. + "claude-sonnet-5": { inputPerM: 2, outputPerM: 10 }, "claude-sonnet-4": { inputPerM: 3, outputPerM: 15 }, "claude-haiku-4-5": { inputPerM: 1, outputPerM: 5 }, "claude-3-5-haiku": { inputPerM: 0.8, outputPerM: 4 }, }; const CODEX_RATES: Record = { - "gpt-5.6-sol": { inputPerM: 5, outputPerM: 30 }, + // Sol's promotional price, published as lasting at least through + // 2026-11-21; the bare `gpt-5.6` id follows Sol. + "gpt-5.6-sol": { inputPerM: 4, outputPerM: 20 }, "gpt-5.6-terra": { inputPerM: 2, outputPerM: 12 }, "gpt-5.6-luna": { inputPerM: 0.2, outputPerM: 1.2 }, - "gpt-5.6": { inputPerM: 5, outputPerM: 30 }, + "gpt-5.6": { inputPerM: 4, outputPerM: 20 }, "gpt-5.3-codex": { inputPerM: 1.75, outputPerM: 14 }, "gpt-5.3": { inputPerM: 1.75, outputPerM: 14 }, "gpt-5-codex": { inputPerM: 1.25, outputPerM: 10 }, @@ -172,11 +185,16 @@ export interface TokenCounts { output: number; } +/** The model's own cache-read price, else the usual share of its input rate. */ +function cacheReadPerM(rate: ModelRate): number { + return rate.cacheReadPerM ?? rate.inputPerM * CACHE_READ_MULTIPLIER; +} + export function costUsd(rate: ModelRate, tokens: TokenCounts): number { return ( (tokens.uncachedInput / 1e6) * rate.inputPerM + (tokens.output / 1e6) * rate.outputPerM + - (tokens.cachedInput / 1e6) * rate.inputPerM * CACHE_READ_MULTIPLIER + + (tokens.cachedInput / 1e6) * cacheReadPerM(rate) + (tokens.cacheWrite5m / 1e6) * rate.inputPerM * CACHE_WRITE_5M_MULTIPLIER + (tokens.cacheWrite1h / 1e6) * rate.inputPerM * CACHE_WRITE_1H_MULTIPLIER ); @@ -184,7 +202,5 @@ export function costUsd(rate: ModelRate, tokens: TokenCounts): number { /** Dollars saved by cache reads vs paying full input price for them. */ export function cacheSavingsUsd(rate: ModelRate, tokens: TokenCounts): number { - return ( - (tokens.cachedInput / 1e6) * rate.inputPerM * (1 - CACHE_READ_MULTIPLIER) - ); + return (tokens.cachedInput / 1e6) * (rate.inputPerM - cacheReadPerM(rate)); } diff --git a/packages/shared/src/agent-models.test.ts b/packages/shared/src/agent-models.test.ts index f7915df5d75..8da5569cd18 100644 --- a/packages/shared/src/agent-models.test.ts +++ b/packages/shared/src/agent-models.test.ts @@ -52,9 +52,10 @@ describe("AGENT_MODEL_SUPPORT", () => { }); describe("SUPERSET_CHAT_MODELS", () => { - it("includes opus 5 and the GPT-5.6 Codex models", () => { + it("includes opus 5, fable 5.1 and the GPT-5.6 Codex models", () => { const ids = SUPERSET_CHAT_MODELS.map((model) => model.id); expect(ids).toContain("anthropic/claude-opus-5"); + expect(ids).toContain("anthropic/claude-fable-5-1"); expect(ids).toContain("openai/gpt-5.6-sol"); expect(ids).toContain("openai/gpt-5.6-terra"); expect(ids).toContain("openai/gpt-5.6-luna"); @@ -112,6 +113,7 @@ describe("buildAgentModelArgs", () => { // Aliases follow the CLI's newest model; teams standardising on one // release need an id that stays put. expect(ids).toContain("opus"); + expect(ids).toContain("claude-fable-5-1"); expect(ids).toContain("claude-opus-4-8"); expect(ids).toContain("claude-opus-4-7"); expect(ids).toContain("claude-sonnet-4-6"); @@ -124,6 +126,7 @@ describe("buildAgentModelArgs", () => { models.find((model) => model.id === id)?.group; expect(groupOf("opus")).toBe("Latest"); expect(groupOf("claude-opus-4-8")).toBe("Pinned releases"); + expect(groupOf("claude-fable-5-1")).toBe("Pinned releases"); // The header carries the distinction, so labels stay bare. expect(models.find((model) => model.id === "opus")?.label).toBe("Opus"); }); @@ -167,6 +170,18 @@ describe("buildAgentModelArgs", () => { ); }); + it("includes fable 5.1 as a pinned claude release and for the models.dev harnesses", () => { + expect(buildAgentModelArgs("claude", "claude-fable-5-1")).toEqual([ + "--model", + "claude-fable-5-1", + ]); + for (const preset of ["opencode", "omp"]) { + expect(buildAgentModelArgs(preset, "anthropic/claude-fable-5-1")).toEqual( + ["--model", "anthropic/claude-fable-5-1"], + ); + } + }); + it("includes every GPT-5.6 Codex model", () => { for (const model of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { expect(buildAgentModelArgs("codex", model)).toEqual(["--model", model]); diff --git a/packages/shared/src/agent-models.ts b/packages/shared/src/agent-models.ts index dd2e599ab78..8500c31dd03 100644 --- a/packages/shared/src/agent-models.ts +++ b/packages/shared/src/agent-models.ts @@ -47,6 +47,11 @@ export const SUPERSET_CHAT_MODELS: readonly SupersetChatModel[] = [ { id: "anthropic/claude-opus-5", label: "Opus 5", provider: "Anthropic" }, { id: "anthropic/claude-opus-4-8", label: "Opus 4.8", provider: "Anthropic" }, { id: "anthropic/claude-opus-4-7", label: "Opus 4.7", provider: "Anthropic" }, + { + id: "anthropic/claude-fable-5-1", + label: "Fable 5.1", + provider: "Anthropic", + }, { id: "anthropic/claude-fable-5", label: "Fable 5", provider: "Anthropic" }, { id: "anthropic/claude-sonnet-4-6", @@ -89,6 +94,7 @@ export const AGENT_MODEL_SUPPORT: readonly AgentModelSupport[] = [ { id: "opus", label: "Opus", group: LATEST_GROUP }, { id: "sonnet", label: "Sonnet", group: LATEST_GROUP }, { id: "haiku", label: "Haiku", group: LATEST_GROUP }, + { id: "claude-fable-5-1", label: "Fable 5.1", group: PINNED_GROUP }, { id: "claude-fable-5", label: "Fable 5", group: PINNED_GROUP }, { id: "claude-opus-5", label: "Opus 5", group: PINNED_GROUP }, { id: "claude-sonnet-5", label: "Sonnet 5", group: PINNED_GROUP }, @@ -166,8 +172,10 @@ export const AGENT_MODEL_SUPPORT: readonly AgentModelSupport[] = [ // openai ids verified against `opencode models` (2026-08-05), which // no longer lists the old `openai/gpt-5`. anthropic ids follow the // same models.dev catalog but need an authed anthropic provider to - // appear in that listing. + // appear in that listing; `claude-fable-5-1` was checked against + // models.dev directly (2026-09-01). { id: "anthropic/claude-opus-5", label: "Claude Opus 5" }, + { id: "anthropic/claude-fable-5-1", label: "Claude Fable 5.1" }, { id: "anthropic/claude-fable-5", label: "Claude Fable 5" }, { id: "anthropic/claude-sonnet-4-5", label: "Claude Sonnet 4.5" }, { id: "openai/gpt-5.6-sol", label: "GPT-5.6 Sol" }, @@ -181,11 +189,13 @@ export const AGENT_MODEL_SUPPORT: readonly AgentModelSupport[] = [ models: [ // OMP accepts configured role aliases as well as exact // provider/model selectors. Exact ids verified against - // `omp models --json` in OMP 18.0.1. + // `omp models --json` in OMP 18.0.1; `claude-fable-5-1` against the + // catalog bundled in OMP 18.1.2. { id: "@smol", label: "Configured fast model" }, { id: "@slow", label: "Configured slow model" }, { id: "@plan", label: "Configured plan model" }, { id: "anthropic/claude-opus-5", label: "Claude Opus 5" }, + { id: "anthropic/claude-fable-5-1", label: "Claude Fable 5.1" }, { id: "anthropic/claude-fable-5", label: "Claude Fable 5" }, { id: "anthropic/claude-sonnet-4-6",