Skip to content

fix(pricing): stop reporting an unpriced rate as free - #930

Merged
sroussey merged 1 commit into
mainfrom
claude/eloquent-gauss-y0d94j-pricing
Sep 9, 2026
Merged

fix(pricing): stop reporting an unpriced rate as free#930
sroussey merged 1 commit into
mainfrom
claude/eloquent-gauss-y0d94j-pricing

Conversation

@sroussey

@sroussey sroussey commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Two independent ways a cost estimate turned "nobody declared this rate" into a confident dollar figure. Same theme, so they travel together.

1. OpenRouter priced an unquoted completion rate at zero

OpenRouterQueuedProvider.modelPricing built its card as:

input,                                        // bails the whole card when undefined
output: perMillion(quoted.completion) ?? 0,
cached: perMillion(quoted.input_cache_read),  // stays undefined

perMillion() deliberately returns undefined for a non-numeric or negative quote — the JSDoc right above it says "-1" "means the rate varies by upstream, which is unpriced rather than negative" — and the module's stated rule is that an unset rate means "not declared here", never zero. The ?? 0 undid that for output alone.

Concrete failure: a router entry such as openrouter/auto quotes { prompt: "0.000003", completion: "-1" } (a partial card omitting completion behaves the same). modelPricing returned { input: 3, output: 0 }, so estimateCost priced a 10k-output-token call at exactly the input cost, set priced = true, left unpriced empty, and formatCost rendered a confident $0.0300 with no ~ marker — silently dropping the side of the bill most of the spend sits on.

Change: drop the ?? 0. output now behaves like cached: unset, so estimateCost lists it in unpriced and the total renders approximate. The card is still returned (the input rate is real), which is the same trade cached already makes.

2. The notTokenBilled guard was asked about the wrong id

resolveModelPricingFromTable strips vendor prefixes and lower-cases the id into id, runs the substring walk against id — but called the guard with the raw modelId.

Gemini's matchers are ^-anchored ([/^imagen-/i, /^gemini-.*-image(?:-|$)/i]), so any id carrying one of the three prefixes the function itself declares walked straight past the guard. Reproduced against the shipped Gemini table before the fix:

gemini-2.5-flash-image-preview          => undefined            (guard fires)
models/gemini-2.5-flash-image-preview   => {input:0.3,output:2.5}  (guard bypassed)
google/gemini-2.5-flash-image-preview   => {input:0.3,output:2.5}  (guard bypassed)

models/… is literally the shape Gemini's ListModels returns, which is why Gemini_ModelSearch strips it. The result is the exact "fabricated unit" the guard exists to prevent: a per-image model resolving gemini-2.5-flash's per-1M-token card, and the run reporting a confident wrong-unit figure. (xAI was unaffected only by luck — its matcher is unanchored.)

Change: pass the normalized id, and say so in the JSDoc, since providers are told to pass the same matcher their effort policy uses.

Tests

  • packages/test/.../OpenRouterPricing.test.ts — quoted prompt with completion: "-1", and a card omitting completion, must leave output unset.
  • packages/ai/.../ModelPricing.test.ts — the notTokenBilled block previously only exercised the no-prefix path; a table-driven case now pairs vendorPrefixes with an anchored matcher, including a mixed-case prefixed id.
  • packages/test/.../ModelSearchPricing.test.ts — the prefixed Gemini forms against the real shipped table, plus the prefixed text sibling still resolving.

Verified

  • All three new cases run red against the unfixed code and green after (both confirmed by running them).
  • bunx vitest run packages/test/src/test/ai-provider-api packages/ai/src examples/cli/src/test/lookupModelPricing.test.ts — 87 files, 985 tests, all pass.
  • bunx turbo run build-types --filter=@workglow/ai --filter=@workglow/openrouter — clean (9 tasks, includes the dependency chain).
  • bunx oxfmt --check and bunx oxlint over the touched trees — clean.

Not verified here: the full repo test suite and the full bun run lint / bun run build. Only the slices above were run.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ


Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
@sroussey
sroussey merged commit 4510121 into main Sep 9, 2026
15 checks passed
@sroussey
sroussey deleted the claude/eloquent-gauss-y0d94j-pricing branch September 9, 2026 18:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants