Uh oh!
There was an error while loading. Please reload this page.
fix(session): guard malformed cost tiers - #43255
Conversation
anomalyco#43248 guarded the cost operands but not the tier selection just above them, which still reads `item.tier.type` and `item.tier.size` unguarded. `cost()` in provider/provider.ts copies `tier: item.tier` straight from the models.dev / plugin payload with no guard of its own, so three shapes still abort the turn: a `tiers` entry missing its `tier`, a null entry, and a non-array `tiers`. Drop malformed tiers instead of trusting them. A non-numeric `size` is excluded rather than coerced to 0, since 0 would make the tier match every context instead of none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enough1122
commented
Aug 22, 2026
— reviewer-h (automated AI reviewer) |
Two follow-ups from review on the first commit:
- a `tier.size` of 0 or a negative number passed `Number.isFinite` and, through
`contextTokens > size`, matched every context — so a corrupt tier won the
selection and billed at its own rates (verified: 108.9 instead of the base
4.5). Non-positive sizes are now dropped.
- the guard also regressed numeric strings. The comparison it replaced coerced
`contextTokens > "5000"`, and a hand-written config can produce one, so
`Number.isFinite("5000")` silently disabled a tier that used to apply.
`size` goes through `Number()` again, and only unparseable or non-positive
values are dropped.
Still deliberately not coerced to 0 on failure: that would match every context
instead of none, which is the bug above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>macurandb
commented
Aug 23, 2026
Thanks — went through all four against the code. Two landed, two didn't. Pushed the fixes. 3 (negative Also fixed, which nobody flagged: my own guard regressed numeric strings. The comparison it replaced coerced 1 (tier without pricing) — the mechanism doesn't hold, but there is a real issue underneath. No NaN: 2 (silent drop) — agreed in principle, not here. 4 (b) — already covered. New tests cover the numeric string and both non-positive sizes. |
…zero-cost A cost tier with a valid shape and size but no finite input/output pricing (missing, undefined, or a numeric string) used to win selection over the base cost and silently zero the turn cost instead of falling back to the base rates. Treat such a tier as malformed and drop it, consistent with the existing guards for non-array tiers, null entries, missing `tier`, and non-positive sizes. Adds a getUsage test for a surviving pricing-less tier (now falls back to base).
macurandb
commented
Aug 25, 2026
Correction to my previous comment: point 1 did land here after all, in So of the four: 1 and 3 fixed, 2 still belongs at the Current numbers, all reproducible against this branch: base rates 4.5 for the test usage; |
Issue for this PR
Closes#43254
Type of change
What does this PR do?
#43248 guarded the operands that go into decimal.js, but not the tier selection two lines above them, which still reads
item.tier.typeanditem.tier.sizeunguarded.cost()inprovider/provider.tscopiestier: item.tierstraight from the models.dev / plugin payload with no guard of its own — unlikeinput/output/cache— so a malformedtiersstill throws out ofgetUsageand takes the turn with it.Three commits, each closing one way a malformed
cost.tiersmisbehaves:tiersthat is not an array, a null entry, or an entry missingtiereach threw.size: 0or a negative number passed the finite check and, throughcontextTokens > size, matched every context — a corrupt tier won the selection and billed at its own rates (108.9 instead of the base 4.5 in the test). Also fixes a regression the first commit introduced: the old comparison coercedcontextTokens > "5000", so requiring anumbersilently disabled tiers whosesizeis a numeric string.sizegoes throughNumber()again; only unparseable or non-positive values are dropped.tierbut no finiteinput/outputwon the selection and, via?? 0, producedcost = 0instead of falling back to the base rates.Throughout, a malformed value is dropped, never coerced to 0 — coercing is what makes a bad tier match every context instead of none.
How did you verify your code works?
Each case was reproduced first and confirmed failing on
devbefore writing the fix:cost.tiersdev[{ input, output, cache }](notier)TypeError: undefined is not an object[null]TypeError: null is not an object{}(not an array)TypeError: ...tiers?.filter is not a function[{ …, tier: { type: "context", size: -1 } }][{ tier: { type: "context", size: 5000 } }](no rates)Five tests in the existing
describe("SessionNs.getUsage")block cover them, plus one asserting a numeric-stringsizestill applies its tier. To confirm: revertsession.tsand re-run that file.bun test test/session/compaction.test.ts→ 61 pass, 1 skip, 0 fail.test/session/llm.test.ts+test/server/negative-tokens-regression.test.ts→ 29 pass.tsgo --noEmitclean, prettier and oxlint clean.Screenshots / recordings
Not a UI change.
Checklist