Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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,
Expand Down
32 changes: 24 additions & 8 deletions packages/host-service/src/trpc/router/usage/history/pricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The API reports the pricing table as updated on September 1 even though this revision re-read the rows on September 2. Set the metadata date to 2026-09-02 so consumers do not display a stale update date.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/host-service/src/trpc/router/usage/history/pricing.ts, line 11:

<comment>The API reports the pricing table as updated on September 1 even though this revision re-read the rows on September 2. Set the metadata date to `2026-09-02` so consumers do not display a stale update date.</comment>

<file context>
@@ -8,11 +8,16 @@ import type { UsageAgent } from "../types";
  * 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 {
</file context>
Suggested change
export const PRICING_TABLE_UPDATED = "2026-09-01";
export const PRICING_TABLE_UPDATED = "2026-09-02";


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;
}

Expand All @@ -22,6 +27,10 @@ export const CACHE_WRITE_5M_MULTIPLIER = 1.25;
export const CACHE_WRITE_1H_MULTIPLIER = 2;

const CLAUDE_RATES: Record<string, ModelRate> = {
// 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 },
Expand All @@ -31,17 +40,21 @@ const CLAUDE_RATES: Record<string, ModelRate> = {
"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<string, ModelRate> = {
"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 },
Expand Down Expand Up @@ -172,19 +185,22 @@ 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
);
}

/** 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));
}
17 changes: 16 additions & 1 deletion packages/shared/src/agent-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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");
});
Expand Down Expand Up @@ -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]);
Expand Down
14 changes: 12 additions & 2 deletions packages/shared/src/agent-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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" },
Expand All @@ -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",
Expand Down
Loading