From e709a9b7f1c347d5a5a1f880ef8297f051a1e64f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 08:22:11 +0000 Subject: [PATCH] fix(pricing): stop reporting an unpriced rate as free MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a cost estimate turned "nobody declared this rate" into a confident number. OpenRouter quoted a completion rate of `-1` — the rate varies by upstream — or omitted it, and `?? 0` priced the output tokens at zero. The estimate then rendered without its `~`, missing the side of the bill most of the spend sits on. `output` now stays unset like `input` and `cached` already do, so the counter is reported unpriced. `resolveModelPricingFromTable` asked the `notTokenBilled` guard about the raw id while the substring walk ran against the prefix-stripped, lower-cased one. Gemini's image matchers are anchored, so `models/gemini-2.5-flash-image-preview` slipped past the guard and took `gemini-2.5-flash`'s per-1M-token card for a model billed per image. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ --- packages/ai/src/model/ModelPricing.ts | 6 ++++-- .../src/model/__tests__/ModelPricing.test.ts | 17 ++++++++++++++++ .../ModelSearchPricing.test.ts | 12 +++++++++++ .../ai-provider-api/OpenRouterPricing.test.ts | 20 +++++++++++++++++++ .../src/ai/OpenRouterQueuedProvider.ts | 2 +- 5 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/ai/src/model/ModelPricing.ts b/packages/ai/src/model/ModelPricing.ts index 2d97f58cd..540e0c1da 100644 --- a/packages/ai/src/model/ModelPricing.ts +++ b/packages/ai/src/model/ModelPricing.ts @@ -123,7 +123,9 @@ const KEY_SUFFIX_BOUNDARY = new Set(["-", "_", ":", "/", "@"]); * fabricated unit, and `undefined` is the honest answer. It is consulted only * after the exact lookups, so a table that deliberately prices an image model * still wins. Providers should pass the SAME matcher their effort policy uses - * rather than a second copy of it. + * rather than a second copy of it — which is why it is asked about the same + * prefix-stripped, lower-cased id the walk matches, so a matcher anchored at + * `^` fires on `models/…` and `google/…` as it does on the bare id. */ export function resolveModelPricingFromTable( table: Readonly>, @@ -149,7 +151,7 @@ export function resolveModelPricingFromTable( // id has deliberately priced it — Gemini prices several image models — and // that is a stronger statement than any matcher. What the guard blocks is the // walk BORROWING a sibling's card for an id the table never named. - if (notTokenBilled?.(modelId) === true) return undefined; + if (notTokenBilled?.(id) === true) return undefined; let best: string | undefined; for (const key of Object.keys(table)) { diff --git a/packages/ai/src/model/__tests__/ModelPricing.test.ts b/packages/ai/src/model/__tests__/ModelPricing.test.ts index 84d416724..96be24a6c 100644 --- a/packages/ai/src/model/__tests__/ModelPricing.test.ts +++ b/packages/ai/src/model/__tests__/ModelPricing.test.ts @@ -115,6 +115,23 @@ describe("resolveModelPricingFromTable", () => { ); }); + it("sees the same id the walk does, prefix stripped and lower-cased", () => { + // A provider writes the matcher for the bare id its table is keyed on, so + // an anchored one only fires if the guard is asked about the stripped + // form. Asked about the raw id, `models/gpt-4o-image` walks straight past + // it and takes `gpt-4o`'s per-token card for a model billed per image. + const anchoredImage = (id: string): boolean => /^gpt-.*-image(?:-|$)/i.test(id); + expect( + resolveModelPricingFromTable(table, "gpt-4o-image", [], anchoredImage) + ).toBeUndefined(); + expect( + resolveModelPricingFromTable(table, "models/gpt-4o-image", ["models/"], anchoredImage) + ).toBeUndefined(); + expect( + resolveModelPricingFromTable(table, "OpenAI/GPT-4o-Image", ["openai/"], anchoredImage) + ).toBeUndefined(); + }); + it("leaves a text id alone, and is optional", () => { expect(resolveModelPricingFromTable(table, "gpt-4o", [], isImage)).toBe(table["gpt-4o"]); expect(resolveModelPricingFromTable(table, "gpt-4o-2024-08-06", [], isImage)).toBe( diff --git a/packages/test/src/test/ai-provider-api/ModelSearchPricing.test.ts b/packages/test/src/test/ai-provider-api/ModelSearchPricing.test.ts index 38a519072..0baf3fbc6 100644 --- a/packages/test/src/test/ai-provider-api/ModelSearchPricing.test.ts +++ b/packages/test/src/test/ai-provider-api/ModelSearchPricing.test.ts @@ -154,4 +154,16 @@ describe("a rate card must match the model's billing unit", () => { expect(getGeminiModelPricing("gemini-2.5-flash-image-preview")).toBeUndefined(); expect(getGeminiModelPricing("gemini-2.5-flash")).toBeDefined(); }); + + it("refuses an unnamed image id carrying one of the prefixes it strips", () => { + // `models/…` is the shape Gemini's ListModels returns, which is why the + // prefix is declared at all. Gemini's image matchers are anchored, so the + // guard has to be asked about the stripped id or a prefixed image model + // resolves the text sibling's per-1M-token card. + expect(getGeminiModelPricing("models/gemini-2.5-flash-image-preview")).toBeUndefined(); + expect(getGeminiModelPricing("google/gemini-2.5-flash-image-preview")).toBeUndefined(); + expect(getGeminiModelPricing("google-gemini/gemini-2.5-flash-image-preview")).toBeUndefined(); + // The prefixed text sibling still resolves; only the borrowing is blocked. + expect(getGeminiModelPricing("models/gemini-2.5-flash")).toBeDefined(); + }); }); diff --git a/packages/test/src/test/ai-provider-api/OpenRouterPricing.test.ts b/packages/test/src/test/ai-provider-api/OpenRouterPricing.test.ts index c827b1759..5f9b18c67 100644 --- a/packages/test/src/test/ai-provider-api/OpenRouterPricing.test.ts +++ b/packages/test/src/test/ai-provider-api/OpenRouterPricing.test.ts @@ -48,6 +48,26 @@ describe("OpenRouter prices from the rate it quoted", () => { expect(priceOf({ prompt: "-1", completion: "-1" })).toBeUndefined(); }); + it("leaves an unpriced completion rate unpriced rather than free", () => { + // A router entry quotes a prompt rate and `-1` for completion when the + // output rate varies by upstream, and a partial card omits the field + // outright. Read as zero, the estimate renders as a confident figure that + // omits the side of the bill most of the spend sits on; left unset, the + // counter is reported unpriced and the total carries its `~`. + expect(priceOf({ prompt: "0.000003", completion: "-1" })).toEqual({ + currency: "USD", + input: 3, + output: undefined, + cached: undefined, + }); + expect(priceOf({ prompt: "0.000003" })).toEqual({ + currency: "USD", + input: 3, + output: undefined, + cached: undefined, + }); + }); + it("reports no card when the record carries no quote at all", () => { expect(priceOf(undefined)).toBeUndefined(); expect(priceOf(null)).toBeUndefined(); diff --git a/providers/openrouter/src/ai/OpenRouterQueuedProvider.ts b/providers/openrouter/src/ai/OpenRouterQueuedProvider.ts index f1a7c88b4..62e2a6bfc 100644 --- a/providers/openrouter/src/ai/OpenRouterQueuedProvider.ts +++ b/providers/openrouter/src/ai/OpenRouterQueuedProvider.ts @@ -64,7 +64,7 @@ export class OpenRouterQueuedProvider extends createCloudProviderClass