Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix: detect out-of-range AI Gateway costs instead of wrapping silently - #27602

Merged
evgeniy-scherbina merged 11 commits into
mainfrom
yevhenii/cc-decimal-lib
Aug 5, 2026
Merged

fix: detect out-of-range AI Gateway costs instead of wrapping silently#27602
evgeniy-scherbina merged 11 commits into
mainfrom
yevhenii/cc-decimal-lib

Conversation

@evgeniy-scherbina

@evgeniy-scherbina evgeniy-scherbina commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Implements: https://linear.app/codercom/issue/AIGOV-448/use-decimal-for-cost-computation
Follow-up to #26229

Follow-up to the AI Gateway cost-control work. Cost is computed per token category as tokens × price / 1_000_000 in int64, then summed. This change makes an unrepresentable result a defined outcome instead of an accident of integer wrap-around.

Motivation

The intermediate tokens × price can exceed int64. Real usage cannot get there: at a $75/M model the product overflows at roughly 123 billion tokens in a single response, about six orders of magnitude above a maxed-out Opus request, so this is not a live incident. The problem is what happens if it ever does, because the sign of the wrapped value silently selects between two different failure modes, neither of which was chosen:

  1. Wraps positive. A plausible-looking cost is stored, incremented into the user's daily spend, and enforced against their AI budget. No error, no signal, wrong number.
  2. Wraps negative. The value violates CHECK (cost_micros >= 0), the insert fails, the surrounding transaction rolls back, and RecordTokenUsage returns a Postgres constraint error that says nothing about overflow. The token usage record is lost entirely, along with its token counts.

So the same class of bad input either corrupts budget accounting or discards an audit record, depending on arithmetic that nobody reasoned about. That is the undefined behaviour.

Decision

An unrepresentable cost is treated as bad input, not a large bill. Since real usage cannot produce one, it can only mean a wrong price row or implausible provider-reported token counts. In both cases the true cost is unknowable, so no number is stored.

Detect rather than avoid. computeCost now evaluates in decimal, so nothing wraps, and range-checks the total against [0, MaxInt64] before converting back. Out of range returns errCostOutOfRange. Rejecting negatives in the same check also keeps them away from the non-negative column constraint, which would otherwise discard the record.

Log, do not block. The error is swallowed at the call site: the record is written with token counts intact and cost_micros NULL, the spend update is skipped, and the condition is logged at ERROR.

Per-category truncation is unchanged. Each category is still truncated independently rather than the total being rounded once, so a per-category breakdown recomputed from the snapshotted price columns sums exactly to the stored total. Every existing computeCost test case passes unmodified.

@github-actions

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cc-decimal-lib branch from 39b1989 to ca32a2e Compare July 28, 2026 23:15
@evgeniy-scherbina

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review starting...
Requested: 2026-07-28 23:31 UTC by @evgeniy-scherbina

deep-review v0.9.0 | Round 1 | 448fe10..ca32a2e

Last posted: Round 1, 16 findings (2 P2, 7 P3, 4 Nit, 3 Note), COMMENT. Review

Finding inventory

Finding inventory

PR #27602. AI Gateway cost out-of-range detection.

Findings

# Sev Status Location Summary Round Reviewer Posted
CRF-1 P3 Open cost.go:107 Out-of-range NULL-cost path increments no metric, unlike the sibling unpriced path R1 Netero Yes
CRF-2 P3 Open aibridgedserver_test.go:3288 ERROR log is the only operator-facing output and no test asserts it R1 Netero Yes
CRF-3 P4 Open coderd/x/chatd/chatcost/chatcost.go:63 Same unrepresentable-cost defect unguarded in chatd, no >= 0 constraint to catch the wrap R1 Netero Yes (body)
CRF-4 P2 Open cost.go:114 Token counts are never validated; persisted implausible counts break SUM(input_tokens)::BIGINT and mixed-sign counts understate cost R1 Knov P2, Kurapika P3, Netero Note Yes (reply)
CRF-5 Note Open CI Storybook job 90435854457 failing, unrelated to this Go-only diff R1 Netero Yes (body)
CRF-6 P2 Open cost.go:132 Ceiling is the cost_micros column width, not what the spend accumulator can absorb, so failure mode 2 survives R1 Hisoka P2, Mafu-san P2, Mafuuu P2, Pariston P2, Chopper P2, Knuckle P2, Kite P2, Meruem P2, Ryosuke P2, Razor P2, Kurapika P3, Knov P3 Yes (reply)
CRF-7 Note Open cost_internal_test.go:116 Ceiling test pins MaxInt64 as valid, encoding CRF-6 as intended behavior R1 Pariston Yes
CRF-8 P3 Open queries/aicostcontrol.sql:117 SUM(spend_micros)::BIGINT and siblings error rather than saturate, locking a user out and breaking org spend reads R1 Knuckle P3, Razor P4 Yes (body)
CRF-9 P3 Open cost.go:145 Div rounds at the mutable global decimal.DivisionPrecision; QuoRem states the precision the code needs R1 Komugi P3, Ging-Go P3, Mafuuu P3, Meruem P3, Razor Note, Ryosuke Note, Hisoka Note, Killua Note, Knov Note, Zoro Note, Bisky Note Yes
CRF-10 P2 Open cost.go:146 Per-category truncation, the invariant the PR promises, is neither tested nor documented; deleting Truncate(0) leaves the suite green R1 Gon P1, Bisky P2, Zoro Nit Yes
CRF-11 P2 Open cost.go:107 ERROR log names a wrong price row as a suspect and logs only token counts; no interception ID either R1 Leorio P2, Chopper P3, Ryosuke P3 Yes
CRF-12 P3 Open cost.go:103 Caller swallows every computeCost error as out-of-range without checking errCostOutOfRange R1 Chopper P3, Meruem P3, Zoro P3, Knov P3 Yes
CRF-13 P3 Open cost.go:51 resolveTokenUsageCost doc drops the NULL-cost discriminator instead of updating it R1 Gon P2, Leorio P3, Mafuuu Nit, Zoro Nit, Knov Nit Yes
CRF-14 P3 Open migrations/000524_aibridge_token_usage_cost.up.sql:13 Schema comment still says NULL cost means unpriced model; now false R1 Razor P3, Chopper Note, Hisoka Nit Yes (body)
CRF-15 P3 Open cost.go:120 computeCost doc omits the error return and the 0 it returns with it; "snapshotting" describes the caller R1 Gon P2, Leorio P3, Kite Nit, Mafuuu Nit, Mafu-san Nit Yes
CRF-16 P3 Open cost_internal_test.go:105 Test names and comments still describe int64 overflow and integer division that this diff removed R1 Gon P2, Bisky Nit Yes
CRF-17 Nit Open aibridgedserver_test.go:3270 expectErrorLog names an assertion the field never makes R1 Gon Nit, Leorio Nit, Netero (part of CRF-2) Yes
CRF-18 Nit Open cost.go:133 Error string reads as a fragment, repeats "cost", and never states the bound violated R1 Gon Nit, Leorio Nit Yes
CRF-19 Note Open cost.go:125 aibridge truncates per category, chatcost applies Ceil to the total; the two engines disagree by up to 4 micro-units R1 Zoro Yes
CRF-20 Note Open cost.go:125 The per-category reconciliation query the PR justifies does not exist, and the natural bigint form overflows where the Go code no longer does R1 Knuckle Yes
CRF-21 Nit Open aibridgedserver_test.go:1906 Two case comments restate the case name and the assertion messages R1 Gon P2 Yes
CRF-22 Nit Open cost.go:104 Second sentence narrates the six log fields beneath it R1 Gon P2 Yes
CRF-23 Note Dropped by orchestrator (verified non-issue: all decimal methods copy before writing, stress-tested clean under -race by Komugi, Killua and Ging-Go independently) cost.go:23 Package-level decimal vars shared across concurrent handlers R1 Komugi Note, Killua Note, Ging-Go Note No

Contested and acknowledged

None.

Round log

Round 1

Netero first pass: 2 P3, 1 P4, 2 Note, no P0-P2, so the panel ran in the same
round. Law not spawned (effective additions 156, below the 1000 threshold).
Panel: 18 trigger-matched plus Knov as wildcard.

Cross-check outcome. Twelve reviewers independently converged on CRF-6, which the
human reviewer @ssncferreira had already raised on the same line. Netero's CRF-4
was upgraded from Note to P2 on Knov's sharper evidence (a persisted
near-MaxInt64 token count breaks ExportOrganizationAISpend for the whole org).

Orchestrator verification, run directly rather than taken from reviewer reports:

  • select 9223372036854775807::bigint + 1::bigint and
    select COALESCE(SUM(v),0)::BIGINT from (values (MaxInt64),(1)) t(v) both
    return ERROR: bigint out of range on the workspace Postgres 16. Confirms the
    overflow-raises-not-wraps premise under CRF-6 and CRF-8.
  • Deleting .Truncate(0) from tokenCost leaves
    go test ./coderd/aibridgedserver/ -run TestComputeCost green. Confirms
    CRF-10 independently of Bisky.
  • coderd/aibridged/http.go:151-155 turns an IsBudgetExceeded error into HTTP
    500. Confirms the user-lockout leg of CRF-6 and CRF-8.

Reachability correction applied during cross-check. Four reviewers framed CRF-6's
trigger as an operator fat-fingering a price row. ai_model_prices is written
only by prices.Seed from an embedded generated JSON price book
(coderd/aibridge/prices/prices.go:38-54, sole caller of
UpsertAIModelPrices); there is no operator-facing price edit path. The highest
price in the shipped book is 600,000,000 micro-units per million tokens, so
reaching a near-MaxInt64 cost needs roughly 1.5e10 tokens in one response. The
trigger is therefore a generator or upstream-feed bug shipping in a release, not
a typo, which is why CRF-6 stayed at P2 rather than being raised.

Reviewed against 448fe10..ca32a2e.

About deep-review

CRF = Coder Review Finding (P0-P4, Nit, Note)

Reviewer Focus
Bisky tests
Chopper ops/errors
Churn-guard change verification
Ging language modernization
Gon naming
Hisoka edge cases
Killua perf
Kite change integrity
Knov contracts
Knuckle SQL
Komugi flake/determinism
Kurapika security
Law decomposition
Leorio docs
Luffy product
Mafu-san process
Mafuuu contracts
Melody dispatch/pairing
Meruem structural
Nami frontend
Netero mechanical checks
Pariston premise testing
Pen-botter product gaps
Razor verification
Robin duplication
Ryosuke Go arch
Takumi concurrency
Zoro shape

🤖 Managed by Coder Agents.

@evgeniy-scherbina
evgeniy-scherbina marked this pull request as ready for review July 28, 2026 23:31
@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

AIGOV-448

Comment thread coderd/aibridgedserver/cost.go Outdated
Comment on lines +29 to +30
// Real usage cannot reach it, so it means a wrong price row or implausible
// provider-reported token counts.

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.

nit: IIRC real usage can reach it, just depends on the currency used (although I think we will probably always stick to USD or EUR). I think we can remove this comment

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.

Checked this one because the panel read it the other way, and both readings are defensible for different reasons.

The claim is accurate under the price book that ships today. ai_model_prices is written only by prices.Seed from the embedded generated JSON (coderd/aibridge/prices/prices.go:38-54), and the highest price in data/prices.json is 600,000,000 micro-units per million tokens, which puts the overflow point around 1.5e10 tokens in one response.

Your currency point is the part worth keeping. Nothing in the schema or the code names a currency; the migrations say only "micro-units (1 unit = 1,000,000)", and the sole place a currency is asserted is the user-facing string at coderd/aibridged/http.go:158-160, which hardcodes US$. If prices were ever quoted in a low-denomination currency, a $600/M model at roughly 16,000 units per USD is about 1e13 micro-units per million tokens, and the overflow point drops to under 1e6 tokens, which a single request with a 1M-token context reaches. So "real usage cannot reach it" is not a property of the arithmetic, it is a property of the implicit USD assumption, and that assumption is enforced by nothing.

So rather than delete the sentence, state what it depends on. Something like "real usage cannot reach it while prices are quoted in USD micro-units" keeps the reasoning that makes the rest of the design make sense (Leorio rated this doc comment as the thing that turns CRF-11 from a mystery into a fixable omission) and stops it from silently becoming false if the price book ever changes denomination.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Now that we've capped the interception cost at $10 million, I think this comment makes sense. We don't expect interceptions to exceed that amount, so we treat them as invalid and simply drop the cost.

Comment thread coderd/aibridgedserver/cost.go Outdated
Comment thread coderd/aibridgedserver/cost.go
@evgeniy-scherbina

Copy link
Copy Markdown
Contributor Author

/coder-agents-review

@coder-agents-review

coder-agents-review Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Chat: Review posted | View chat
Requested: 2026-07-29 13:00 UTC by @evgeniy-scherbina
Spend: $39.69 / $100.00

Review history
  • R1 (2026-07-29): 19 reviewers, 4 Nit, 3 Note, 2 P2, 7 P3, COMMENT. Review

🤖 Managed by Coder Agents.

@coder-agents-review coder-agents-review Bot left a comment

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.

Evaluating in decimal and range-checking the total is the right shape. The degrade-to-NULL sits next to the existing unpriced-model degrade rather than introducing a new pattern, per-category truncation was preserved instead of quietly rewritten (Razor compared the old and new implementations over 100,300 randomized cases including negative counts and got identical results), the two NULL-cost causes stay separable in the data because the out-of-range path returns after the price columns are set, and the new RPC case asserts the negative as well as the positive with IncrementUserAIDailySpend at Times(0). Killua benchmarked the rewrite at 3.0us and 64 allocs per record against a call that already makes four database round trips: "Swapping int64 arithmetic for decimal on a per-response path is the kind of thing I get paid to shoot down. I measured it. It's fine."

4 P2, 9 P3, 1 P4, 4 Nit, 4 Note.

One finding dominates. Twelve of the nineteen panel reviewers independently landed on the same line, and @ssncferreira had already raised it in review: maxCostMicros is the width of the cost_micros column, but the accepted value is immediately added into ai_user_daily_spend.spend_micros, a running bigint total, inside the same transaction as the insert. Postgres raises on bigint overflow rather than wrapping, so a cost the new check accepts still produces failure mode 2 from the description, and the new ceiling test pins math.MaxInt64 as valid. Details in the reply on that thread (CRF-6). CRF-4 is the input-validation half of the same gap, replying on your tokens thread.

One correction to the panel's framing, since four reviewers built their scenario on it and it would push the fix too far. ai_model_prices is written only by prices.Seed from an embedded generated price book (coderd/aibridge/prices/prices.go:38-54 is the sole caller of UpsertAIModelPrices), so there is no operator-facing price edit path and "someone fat-fingers a price row" is not reachable today. The highest price in the shipped book is 600,000,000 micro-units per million tokens, which puts the overflow point around 1.5e10 tokens in one response. The trigger for CRF-6 is a generator or upstream-feed bug shipping in a release, or provider-reported counts at that magnitude, which is the same implausible-input class this PR exists to define behavior for. That is why CRF-6 is P2 and not higher.

Verified directly rather than relayed: select 9223372036854775807::bigint + 1::bigint and select COALESCE(SUM(v),0)::BIGINT from (values (MaxInt64),(1)) t(v) both return ERROR: bigint out of range on Postgres 16; deleting .Truncate(0) from tokenCost leaves go test ./coderd/aibridgedserver/ -run TestComputeCost green; coderd/aibridged/http.go:151-155 turns an IsBudgetExceeded error into HTTP 500.

Two process notes. CI shows Storybook failing (run 90435854457); git diff --name-only 448fe10d..ca32a2e8 returns three .go files under coderd/aibridgedserver/ and nothing under site/, so it is not caused by this change, but it still needs a cause before merge. And the commit subject fix: detect out-of-range AI Gateway costs instead of wrapping silently satisfies every AGENTS.md rule except the scope, which all three changed files share: fix(coderd/aibridgedserver): detect out-of-range costs instead of wrapping silently is shorter, because "AI Gateway" becomes redundant with the scope.

The PR description is the best artifact in this review. It states why the change exists when the bug cannot fire in practice, quantifies the claim, enumerates both failure modes with their distinct consequences, records the decision that is invisible in the diff, and restates the diff nowhere. Do that again.


coderd/aibridgedserver/cost.go:120

P3 [CRF-15] computeCost gained an error return and its doc still describes a total function. (Gon P2, Leorio P3, Kite Nit, Mafuuu Nit, Mafu-san Nit)

The doc covers price semantics and says nothing about when the second return value is non-nil, which is the only thing this PR changed about the function. A caller reading it cannot tell that the error is a defined, expected outcome to be swallowed rather than a failure to propagate, which is exactly the decision the caller makes at line 103.

Leorio adds the sharper half: the function returns (0, err) and documents neither. 0 is a perfectly legal cost that passes cost_micros >= 0 and reads downstream as "this request was free", so the only thing between the codebase and the corrupted-accounting outcome this PR exists to prevent is one caller that happens to check err.

Also worth one sentence on why tokenCost returns decimal.Decimal at all. Nothing in the repository records it, and the next reader sees three allocations where an int64 multiply and divide would do.

Separately, "snapshotting the per-token prices from the price table" describes what the caller does. Snapshotting happens at lines 95 to 98 in resolveTokenUsageCost; computeCost only reads the struct. "using" is accurate.

🤖

coderd/aibridgedserver/cost_internal_test.go:105

P3 [CRF-16] Test names and comments still describe the int64 overflow and integer division that this diff removed. (Gon P2, Bisky Nit)

computeCost no longer performs int64 arithmetic, so nothing in it can overflow. Four instances still say it does:

  • Lines 105 to 107: "Stress the per-term numerator near the int64 ceiling. At a $75/M model the overflow point is ~123e9 tokens ... 122e9 stays just under." The case's stated purpose is gone; what remains is that a large in-range cost computes correctly.
  • Line 108: case name "large token count at a high price does not overflow".
  • Line 72: case name "integer division truncates". There is no integer division.
  • Line 36: "10*3_750_000/1e6 = 37, integer division". The mechanism is now decimal.Truncate(0).

A reader trusting these will look for int64 products that are not there. Gon suggests "large token count at a high price computes exactly" and "truncates toward zero", and keeping the arithmetic on line 112 while dropping < int64 max.

🤖

coderd/x/chatd/chatcost/chatcost.go:63

P4 [CRF-3] The identical unrepresentable-cost defect this PR fixes exists unguarded in the chatd cost path, and there it stores the wrapped value. (Netero)

CalculateTotalCostMicros sums tokens x pricePerMillion in decimal, then calls total.Ceil().IntPart() with no range check. decimal.IntPart() is undefined above MaxInt64: it returns big.Int.Int64(), the low 64 bits. Verified against shopspring/decimal v1.4.0, the version in go.mod:558:

total: 18446744073709551614   (2 x MaxInt64)
Ceil().IntPart(): -2

chat_messages.total_cost_micros has no >= 0 check constraint (dump.sql:1962), so unlike the aibridge column the wrapped negative is stored rather than rejected, and it is summed into spend aggregates (chats.sql:2188, :2446). That is failure mode 1 from this PR's motivation, with no constraint to catch it.

Outside this diff and outside the stated task. Flagged because it is the same class and the description's reasoning applies to it unchanged, so the decision to fix it here or file it should be made rather than defaulted.

🤖

coderd/database/queries/aicostcontrol.sql:117

P3 [CRF-8] SUM(...)::BIGINT raises rather than saturating across five spend queries, which is what turns CRF-6 from a lost record into a lockout. (Knuckle P3, Razor P4)

SUM(bigint) returns numeric in Postgres, so the sum survives; the cast back does not. Verified: select coalesce(sum(v),0)::bigint from (values (9223372036854775807::bigint),(1::bigint)) t(v) returns ERROR: bigint out of range.

Reaching it does not require the increment overflow from CRF-6. A ceiling-range cost on a fresh (user, group, day) key takes the plain INSERT path and commits cleanly; any spend on any other day in the period then pushes the window sum over the line. GetUserAISpendSince fails, checkUserAIBudget propagates (aibridgedserver.go:894-901), and coderd/aibridged/http.go:151-155 answers every request with HTTP 500. The same cast sits on GetOrganizationGroupsAISpend:132, GetGroupMembersAISpend, GetOverBudgetUsersPerGroup and ExportOrganizationAISpend:328, so the org spend page and the export go down with it. detectBudgetThresholdCrossings hits the same query inside the spend transaction (notifications.go:90) but swallows the error, so there it only silences notifications.

Outside this diff, and unreachable if CRF-6's ceiling moves. Listed because it is the read side of the same boundary this PR decides the terms of, and because clamping with LEAST(COALESCE(SUM(spend_micros), 0), 9223372036854775807::numeric)::BIGINT degrades one number instead of every query that touches the table.

🤖

coderd/database/migrations/000524_aibridge_token_usage_cost.up.sql:13

P3 [CRF-14] The only durable documentation of cost_micros now states a rule the code does not follow. (Razor P3, Chopper Note, Hisoka Nit)

The migration comment reads "Computed cost in micro-units at interception time. NULL if the model is not present in ai_model_prices." After this PR, NULL also means out of range, with every price column populated. The Go doc comment was updated (modulo CRF-13); this one is what a query author or analyst actually reads, since dump.sql:1649-1657 carries the CHECK constraints but no column comment. Anyone writing WHERE cost_micros IS NULL as "unpriced model" now gets a wrong answer.

The migration is applied and shipped in v2.35.0, so it must not be edited. The fix is a new migration adding COMMENT ON COLUMN aibridge_token_usages.cost_micros, which lands in dump.sql where readers look. The useful content is the discriminator: NULL prices mean unpriced, populated prices mean the cost could not be represented.

Raising it so the choice is made rather than defaulted; accepting the drift knowingly is a decision, defaulting into it is not.

🤖

🤖 This review was automatically generated with Coder Agents.

Comment thread coderd/aibridgedserver/cost.go Outdated
return decimal.NewFromInt(tokens).
Mul(decimal.NewFromInt(pricePerMillion.Int64)).
Div(tokensPerMillion).
Truncate(0)

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.

P2 [CRF-10] Per-category truncation, the one invariant the description explicitly guarantees, is pinned by no test and documented nowhere in the repo. It is now a single deletable method call. (Gon P1, Bisky P2, Zoro Nit)

Under the old code the property was free: tokens * price / 1_000_000 in int64 had no choice but to truncate per term. Now Truncate(0) is the only thing producing it, and IntPart() at the total would silently take over if it went away.

Bisky deleted it and ran the suite. The orchestrator reproduced this independently on the review worktree:

$ # .Truncate(0) removed from tokenCost
$ go test ./coderd/aibridgedserver/ -run TestComputeCost -count=1
ok      github.com/coder/coder/v2/coderd/aibridgedserver 0.052s

All thirteen cases stay green because only one has a fractional category and three of its four terms are exact, so truncating per category and truncating the total both give 1552. Every other fractional case is single-category, where the two are identical by construction. Bisky's case closes it, verified passing at 74 on current code and failing at 75 with Truncate(0) removed:

{
	// Two categories each with a 0.5 remainder. Truncating per category
	// gives 37 + 37; truncating the total once would give 75.
	name: "each category truncates before the sum",
	price: database.AIModelPrice{
		InputPrice:  nullInt64(3_750_000),
		OutputPrice: nullInt64(3_750_000),
	},
	inputTokens:  10,
	outputTokens: 10,
	want:         74,
},

The consequence is a silent one. Someone simplifies tokenCost, or moves the truncation to the total, gets a green suite, and stored cost_micros stops matching a breakdown recomputed from the snapshotted price columns. Off-by-up-to-four micro-units per record, on a number that feeds daily spend and budget enforcement, with nothing to catch it.

Gon rated this P1 for the missing comment specifically, since the rationale lives only in the PR description, which the next reader will not have. Fix both halves: add the case, and put the why on tokenCost where Gon and Zoro both proposed it. CRF-9's QuoRem fix retires most of this by making truncation the only thing the expression can do.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: 6bd034c

// No trustworthy cost exists, so record it as unknown rather than
// storing a figure derived from bad inputs. The token counts are logged
// because the range error alone does not say which input was wrong.
s.logger.Error(ctx, "cost out of range, recording token usage with NULL cost",

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.

P2 [CRF-11] The errCostOutOfRange doc names two suspects; the log records only one of them, and identifies no record. (Leorio P2, Chopper P3, Ryosuke P3)

The comment two lines up says token counts are logged "because the range error alone does not say which input was wrong", which is the right instinct. But errCostOutOfRange's own doc says the condition means "a wrong price row or implausible provider-reported token counts", and only one of the two is in the log.

Leorio traced the case that matters: a price row six orders of magnitude high makes every token count in the log entry look completely normal. The operator sees plausible tokens, a 20-digit total, and no reason to suspect the price table. price is in scope from line 78.

Chopper and Ryosuke add the other half. No field identifies the affected record, so an operator holding "cost out of range for anthropic/claude-sonnet-4-6" cannot reach the aibridge_token_usages row, the interception, or the user whose spend is now understated; on a busy deployment the row has to be recovered by matching provider, model and token counts inside a timestamp window. Every other diagnostic in this package carries one: the skipped-spend Debug logs interception_id and initiator_id (aibridgedserver.go:409-410), the threshold-detection Error logs both (:435-436), the unresolved-group Warn logs user_id (cost.go:66).

This matters more here than for the sibling unpriced Debug log at line 85, which omits the ID too, because that path is a benign recurring condition with a metric behind it while this one is a per-record anomaly whose entire purpose is to be investigated. intc.ID, intc.InitiatorID and the four price fields are all in scope at this call.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added interception_id and initiator_id (9286dbe). The pricing is defined in prices.json and embedded into the binary, so it's unlikely to be incorrect. If needed, it can always be derived from prices.json by provider and model.

Comment thread coderd/aibridgedserver/cost.go Outdated
return tokens * pricePerMillion.Int64 / tokensPerMillion
return decimal.NewFromInt(tokens).
Mul(decimal.NewFromInt(pricePerMillion.Int64)).
Div(tokensPerMillion).

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 [CRF-9] Div routes every stored cost through decimal.DivisionPrecision, a mutable package global, where the old expression depended on nothing. (Komugi P3, Ging-Go P3, Mafuuu P3, Meruem P3, plus Notes from Razor, Ryosuke, Hisoka, Killua, Knov, Zoro, Bisky)

Div is d.DivRound(d2, int32(DivisionPrecision)) and DivisionPrecision is a plain var ... = 16 ([email protected] decimal.go:44, :590-592) that any package linked into the binary can reassign. The result is correct today because the quotient of an integer by 1e6 terminates within six fractional digits, so rounding at sixteen is a no-op. That correctness is a property of the default, not of the expression.

Three reviewers forced it independently and got the same shape. Komugi:

DivisionPrecision=16 -> 1 (err=<nil>)
DivisionPrecision=2  -> 2 (err=<nil>)

DivRound rounds half away from zero before Truncate(0) runs, so below six places the documented truncation silently becomes rounding up. Komugi swept a sample set and found divergences from the original int64 expression at (1, 1_999_999) -> 2 want 1, (999_999, 1) -> 1 want 0, (123, 999_999) -> 123 want 122, and on negatives at precision 0. Nothing in the repo assigns the global (four reviewers grepped independently), which is why this is P3 and not higher.

The fix states the precision the code actually needs and deletes the Truncate(0) step, since the quotient is already integral:

q, _ := decimal.NewFromInt(tokens).
    Mul(decimal.NewFromInt(pricePerMillion.Int64)).
    QuoRem(tokensPerMillion, 0)
return q

Komugi ran this over the same sample set at DivisionPrecision 16, 2 and 0 and matched the original int64 expression on every non-overflowing case, negatives included, since big.Int.QuoRem truncates toward zero exactly like Go's /. This also makes per-category truncation structural rather than a deletable call, which is most of CRF-10. Razor measured a math/big variant at 589ns against 3759ns for the current code, noting performance is irrelevant at this call rate and mentioning it only because the simpler option is also the faster one.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed

costMicros, err := computeCost(price,
in.GetInputTokens(), in.GetOutputTokens(),
in.GetCacheReadInputTokens(), in.GetCacheWriteInputTokens())
if err != nil {

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 [CRF-12] The caller converts every computeCost error into "cost unknown, store NULL", so the sentinel is discriminated nowhere outside the test. (Chopper P3, Meruem P3, Zoro P3, Knov P3)

errCostOutOfRange exists so a caller can tell "unrepresentable, keep going" apart from "something else went wrong". The only errors.Is against it is cost_internal_test.go:162. Production does if err != nil and then logs a message that asserts a specific cause.

Today computeCost has exactly one failure mode, so nothing is misreported. What is wrong is the contract: the next error added to computeCost, for any reason, is silently downgraded to a NULL cost under a log line naming the wrong diagnosis, with no code change and no review signal. Chopper called it the unhandled middle inverted, handling all errors as one known error.

The file already has the shape twelve lines up, where the price lookup separates the known case from the rest. errors is already imported:

switch {
case errors.Is(err, errCostOutOfRange):
	// No trustworthy cost exists, so record it as unknown rather than
	// storing a figure derived from bad inputs.
	s.logger.Error(ctx, "cost out of range, recording token usage with NULL cost", ...)
	return result, nil
case err != nil:
	return tokenUsageCost{}, xerrors.Errorf("compute cost for %s/%s: %w", intc.Provider, intc.Model, err)
}

Meruem's alternative is equally valid and cheaper: if discrimination is not wanted, drop the sentinel, because an unchecked sentinel is a distinction the program does not make.

🤖

@evgeniy-scherbina evgeniy-scherbina Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Makes sense, but I don't expect computeCost to change much. In any case, if computeCost fails - there is nothing better than set cost to NULL, but don't fail recording.

// No trustworthy cost exists, so record it as unknown rather than
// storing a figure derived from bad inputs. The token counts are logged
// because the range error alone does not say which input was wrong.
s.logger.Error(ctx, "cost out of range, recording token usage with NULL cost",

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 [CRF-1] This is the only NULL-cost outcome with no metric, so the condition the PR exists to surface is detectable only by log scraping. (Netero)

The sibling NULL-cost condition four blocks up increments a counter (cost.go:87-89, s.metrics.UnpricedTokenUsageRecords). Two consequences. unpriced_token_usage_records_total, documented in docs/admin/integrations/prometheus.md:119 as counting records "for which no model price was found", no longer accounts for every NULL cost_micros, so a dashboard reconciling "records with NULL cost" against that counter silently disagrees. And the new condition has no numeric signal at all.

metrics.go:25-34 already carries provider/model-labelled counters, so this is one field plus one Inc() with the same labels. s.metrics is nil-checked at cost.go:87, so the same guard applies here.

🤖

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think we need a metric for this extremely rare scenario.

tokenCost(cacheReadTokens, price.CacheReadPrice) +
tokenCost(cacheWriteTokens, price.CacheWritePrice)
func computeCost(price database.AIModelPrice, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens int64) (int64, error) {
total := tokenCost(inputTokens, price.InputPrice).

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.

Note [CRF-20] The per-category reconciliation the description justifies has no query yet, and the natural form of it overflows in the one language you just left. (Knuckle)

The PR justifies per-category truncation by saying a breakdown recomputed from the snapshotted price columns sums exactly to the stored total. No such query exists: input_price_micros and its siblings are write-only, referenced solely by InsertAIBridgeTokenUsage (queries/aibridge.sql:46).

When someone writes it, the natural form is input_tokens * input_price_micros / 1000000, and bigint * bigint raises exactly where the old Go code wrapped (verified: select 9223372036854775807::bigint * 2::bigint returns ERROR: bigint out of range). Whoever writes that query needs ::numeric on the multiply. One line in the column comment, while the reasoning is fresh, is cheaper than rebuilding the invariant in the one language that still fails at the same boundary.

🤖

// expectErrorLog tolerates ERROR-level logs, which slogtest otherwise
// treats as a test failure. Set it only for cases whose expected behavior
// includes logging an error, so every other case stays strict.
expectErrorLog bool

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.

Nit [CRF-17] expectErrorLog names an assertion the field never makes. (Gon Nit, Leorio Nit)

The field's only effect is IgnoreErrors: true at line 3289. Nothing verifies an error log was emitted, and setting it on a case that logs nothing passes silently. The field's own doc gets the verb right on the first word, "tolerates", while the identifier says "expect", which in test-helper vocabulary means the test fails if it does not happen. The next person to write expectErrorLog: true will believe they have coverage of the log.

ignoreErrorLogs or tolerateErrorLogs describes what it does, and the doc then shortens to match the name instead of correcting it. Distinct from CRF-2: renaming fixes the mismatch without adding an assertion, and adding the assertion would not fix the name.

🤖

Comment thread coderd/aibridgedserver/cost.go Outdated
// Rejecting the negative case here keeps it from reaching the
// cost_micros >= 0 check constraint, which would discard the whole record.
if total.IsNegative() || total.GreaterThan(maxCostMicros) {
return 0, xerrors.Errorf("cost %s micro-units: %w", total.String(), errCostOutOfRange)

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.

Nit [CRF-18] The wrapped error reads as a fragment and never states the bound it violated. (Gon Nit, Leorio Nit)

Rendered output, which Leorio produced with a scratch test:

cost 18446744073709551614 micro-units: computed cost is out of range
cost -3000000 micro-units: computed cost is out of range

The context clause is not a phrase, "cost" appears twice, and the operator gets a 20-digit number plus the words "out of range" with no way to know what range without reading Go source. Sentence first, detail after, and decimal.Decimal implements fmt.Stringer so .String() is redundant under %s:

return 0, xerrors.Errorf("%w: %s micro-units", errCostOutOfRange, total)

Leorio would go further and put the bound in the sentinel itself, where it costs nothing and is right every time. If CRF-6 moves the ceiling, name the new bound there.

🤖

in.GetInputTokens(), in.GetOutputTokens(),
in.GetCacheReadInputTokens(), in.GetCacheWriteInputTokens())
if err != nil {
// No trustworthy cost exists, so record it as unknown rather than

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.

Nit [CRF-22] The second sentence narrates the six log fields directly beneath it. (Gon P2)

"The token counts are logged because the range error alone does not say which input was wrong" tells the reader that lines 109 to 112 log token counts, which those lines already show, wrapped in a rationale that is the obvious purpose of a diagnostic log. The first sentence carries the actual why-not-what, the decision to record NULL rather than return an error or store a figure.

// No trustworthy cost exists, so record it as unknown rather than storing a
// figure derived from bad inputs.

(CRF-11 argues the fields themselves are incomplete, which is the substantive half of this statement.)

🤖

},
},
{
// Token counts large enough that the cost cannot be stored. The

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.

Nit [CRF-21] Two comments in this case restate the case name and the assertion messages. (Gon P2)

"Token counts large enough that the cost cannot be stored" restates name: "valid token usage with cost out of range" plus InputTokens: math.MaxInt64. "The record is written anyway, with prices snapshotted and cost NULL" restates the assertions at lines 1929 to 1932, whose own messages already read "input price populated" and "cost null". Line 1934, // Prices and tokens are populated even though cost is NULL., restates the same three assertions a second time eight lines later.

Delete both; the case name and the assertion messages carry it. Note the contrast with lines 1924 and 1944 in the same case, which Gon explicitly kept: those decode a literal (2_000_000 means two micro-units per token) and give a reason (Times(0) because cost is NULL) that the code cannot show.

🤖

@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cc-decimal-lib branch from f483e0e to 69b0fa0 Compare July 29, 2026 17:40
@evgeniy-scherbina
evgeniy-scherbina force-pushed the yevhenii/cc-decimal-lib branch from eddd66f to 6bd034c Compare July 29, 2026 17:59

@ssncferreira ssncferreira left a comment

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.

LGTM 👍

Comment thread coderd/aibridgedserver/cost.go Outdated

// Rejecting the negative case here keeps it from reaching the
// cost_micros >= 0 check constraint, which would discard the whole record.
if total.IsNegative() || total.GreaterThan(maxCostMicros) {

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.

nit: moving this to validateTotalCost() to be consistent with validateTokenUsage

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed: 1792455

slog.F("input_tokens", in.GetInputTokens()),
slog.F("output_tokens", in.GetOutputTokens()),
slog.F("cache_read_input_tokens", in.GetCacheReadInputTokens()),
slog.F("cache_write_input_tokens", in.GetCacheWriteInputTokens()),

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.

It might be useful to include provider and model here as well.
Additionally, it might make sense to create a metric for this, with provider and model as well. Can be done in a follow-up PR.

@evgeniy-scherbina evgeniy-scherbina Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

  1. The provider and model variables are not in scope. It's possible to refactor this, but it turns out that would require changes to both the code and the tests. I guess it's not worth it?
  2. I'll create a follow-up

Comment thread coderd/aibridgedserver/cost.go
s.logger.Error(ctx, "cost out of range, recording token usage with NULL cost",
slog.F("interception_id", intc.ID),
slog.F("initiator_id", intc.InitiatorID),
slog.F("provider", intc.Provider), slog.F("model", intc.Model),

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.

Another metric here as well. In fact, thinking about it better, we should have a metric for all the NULL cases 🤔 we already have for the unpriced model, but we should have for the other 2 cases.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll create a follow-up

@evgeniy-scherbina
evgeniy-scherbina merged commit dae41eb into main Aug 5, 2026
28 checks passed
@evgeniy-scherbina
evgeniy-scherbina deleted the yevhenii/cc-decimal-lib branch August 5, 2026 18:47
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 5, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants