fix(acp): price cache and thought tokens, and stop stacking derived cost - #4444
fix(acp): price cache and thought tokens, and stop stacking derived cost#4444onatozmenn wants to merge 3 commits into
Conversation
Three defects in ACP cost accounting, all in _record_usage's derivation path. _estimate_cost_from_tokens priced only input and output. ACP reports every bucket separately (Usage.total_tokens is documented as the sum of all token types, and metrics.py already notes that ACP reports cached reads outside prompt_tokens), so cache reads, cache writes and thought tokens were dropped from the estimate entirely. A cache-heavy gemini-cli session is under-billed, not merely mis-billed. Cache buckets now price at the model's cache rates and fall back to the input rate; thought tokens price as output. The derived estimate keyed off cost_recorded, which is only set when a UsageUpdate arrives with a positive delta. Provider cost is cumulative, so a second UsageUpdate carrying the same amount yields delta == 0 and the token estimate was added on top of cost already recorded. It now keys off whether the provider has ever reported a cost for the session. An unpriced model returned 0.0 silently, so the turn recorded no cost at all with no signal. It now warns once per model. Reported by @EvolveAegis in OpenHands#4382, including the dynamic reproduction. Signed-off-by: onatozmenn <onatozmen44@gmail.com>
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
Three cost-accounting fixes in ACPAgent._record_usage, all correct in direction and well-tested:
- Cache + thought tokens priced — ACP reports these in separate buckets (not folded into
input_tokenslike litellm/OpenAI), so pricing them additively is the right call. Themetrics.pycache_hit_ratedoc already documents this bucket separation, which corroborates the approach. - Derived cost no longer stacks on provider cost — Keying the derived-estimate guard on
session_id in _last_cost_by_session("provider ever reported") instead ofcost_recorded("this call's delta > 0") correctly handles the repeated-cumulative-amount case wheredelta == 0. I verified the guard works:_last_cost_by_session[session_id]is set unconditionally inside theusage_update.cost is not Noneblock (line 1907), so any provider cost report — even adelta == 0one — marks the session. - Unpriced-model warning —
@cacheon_warn_unknown_acp_pricingkeeps it to one line per model per process, which is appropriate for a warning.
Tests are solid: 8 new unit tests cover all four behaviors, and the stacking-guard test correctly fails against the old code (derived cost would be ~0.002 on the second call). All 12 new tests pass.
Risk Assessment
Low risk. The changes are isolated to ACP cost derivation, backward compatible (the _estimate_cost_from_tokens signature keeps its positional args with new optional kwargs), and don't touch agent behavior, prompts, or tool execution. No security concerns.
Findings
One inline finding (medium): or fallback over-charges models with explicitly free cache rates
See inline comment on line 846. info.get(key) or input_cost treats 0.0 as falsy, so a model whose litellm cost map explicitly sets cache_read_input_token_cost=0 or cache_creation_input_token_cost=0 (free cache reads/writes) gets priced at the input rate instead of $0. I confirmed this against real litellm data: 17 models have cache_read_input_token_cost=0 and 31 have cache_creation_input_token_cost=0. For example, deepseek/deepseek-chat (free cache writes) charges 100 cache-write tokens at $2.8e-05 instead of $0. The fix is info.get(key, input_cost) — returns input_cost only when the key is absent, and respects an explicit 0.0.
Minor: first-call $0 provider cost scenario untested
The stacking-guard test uses amount=10.0 (delta > 0 on first call). The edge case where the provider's first UsageUpdate has cost.amount=0.0 is handled correctly by the new guard (provider_cost_seen becomes True while cost_recorded stays False), but there's no test asserting it. Not blocking — the core delta==0 scenario is covered — but a one-line test would lock in the behavior.
Note: reasoning tokens priced at output rate
The PR description flags this as a judgement call. Pricing reasoning/thinking tokens at output_cost matches how Anthropic and Gemini bill them, so this is reasonable. Some litellm cost-map entries have model-specific reasoning rates, but those are uncommon and the output-rate default is a sound choice for now.
`info.get(key) or input_cost` cannot tell "no cache rate for this model" from "cache is free here", so models that set the rate to 0 were charged the plain input rate. In litellm's current cost map that is 17 entries for cache reads and 31 for cache writes, `deepseek/deepseek-chat` among them. Using a `dict.get` default keeps the fallback for models that omit the key and respects an explicit 0. Signed-off-by: onatozmenn <onatozmen44@gmail.com>
|
🚦 CI is currently failing on this PR's latest commit. Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request This is an automated check - no AI was used to generate this comment. |
A provider whose first UsageUpdate carries `cost.amount=0.0` already blocks the derived estimate, since the guard keys on whether the session was ever reported rather than on the delta. Nothing asserted that, so the behaviour could regress back to the delta check without a test noticing. Signed-off-by: onatozmenn <onatozmen44@gmail.com>
|
The red It's a pre-existing flake on The mechanism is visible in the test: it starts ten threads, then sets their events 2ms apart and expects that to fix the queue order. On a busy runner a thread can miss its 2ms window, and the next one enqueues first. I could not reproduce it locally in 50 runs, including 20 under full CPU load, which fits, this box has 20 cores and 2ms is a lot of room here. I can't re-run the job from a fork, so I pushed Happy to send the fairness test as its own PR if you want it made deterministic rather than left to timing. |
|
🤖 OpenHands is reviewing this PR. Head commit: This comment was posted by an AI agent (OpenHands). |
all-hands-bot
left a comment
There was a problem hiding this comment.
This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.
Summary
This PR fixes three cost-accounting defects in ACPAgent._record_usage, all correctly addressed since the prior review (commit 015b2fe):
- Cache and thought tokens are now priced.
_estimate_cost_from_tokensacceptscache_read_tokens,cache_write_tokens, andreasoning_tokensand prices them additively. This is the right call for ACP'''s bucket semantics —Usage.total_tokensis the sum of all types (confirmed bycache_hit_rate'''s docstring inmetrics.py), so cached reads are a separate billable bucket, not a discountable subset ofinput_tokens. - Derived cost no longer stacks on provider cost. The guard now keys on
session_id in self._client._last_cost_by_session("provider ever reported") instead ofcost_recorded("this call'''s delta > 0"). Since_last_cost_by_session[session_id]is set unconditionally inside theusage_update.cost is not Noneblock (line 1908), a repeated cumulative amount (delta == 0) correctly blocks the derived path. - Unpriced models warn once.
@cacheon_warn_unknown_acp_pricingkeeps it to one warning per model per process, consistent with the existing@cachepattern indeprecation.pyandmodel_features.py.
Previous review findings — all resolved
orfallback over-charging free cache rates (medium, line 846 in015b2fe): Fixed. Lines 847-848 now useinfo.get(key, input_cost)instead ofinfo.get(key) or input_cost, correctly distinguishing a missing rate (fall back to input) from an explicit 0.0 (free). Thetest_explicit_zero_cache_rate_is_freetest locks this in.- First-call $0 provider cost untested (minor): Fixed.
test_provider_reporting_zero_cost_blocks_the_derived_estimatecovers the case where the firstUsageUpdatehascost.amount=0.0—provider_cost_seenbecomes True via the unconditional_last_cost_by_session[session_id] = 0.0assignment, and the derived estimate is correctly blocked. - Reasoning tokens at output rate (note): Confirmed reasonable — matches Anthropic/Gemini billing.
Risk Assessment
Low risk. Changes are isolated to ACP cost derivation. The _estimate_cost_from_tokens signature keeps its positional (model, input, output) args with new optional kwargs, so existing callers are unaffected. No changes to agent behavior, prompts, tool execution, or event schemas. No security concerns.
Findings
No material findings. The implementation is correct, well-tested (12 new tests covering all four behaviors), and the previous review'''s inline finding has been resolved with the dict.get default pattern.
One minor observation (not actionable): the unpriced-model guard at line 843 (if not input_cost and not output_cost) would warn for a hypothetical model with both rates explicitly set to 0.0 in litellm'''s cost map, framing it as "no pricing" when it'''s actually "free." No real-world litellm entry has both rates at 0, so this is theoretical and the current behavior (warn + $0) is safe either way.
HUMAN:
Before touching any of the math I wanted to be sure cache tokens weren't already inside the input count. Turns out ACP keeps them in separate buckets, so they were going through completely unpriced. The other two were more clear cut.
AGENT:
Why
Fixes #4382.
Three defects in ACP cost accounting, all reachable from
_record_usage.1. Cache and thought tokens are never priced.
_extract_token_usagepulls five buckets out of the ACPUsage, but_estimate_cost_from_tokens(model, input_tokens, output_tokens)only ever sees two of them.Worth being precise about the direction of this one, because it is the opposite of what it looks like. ACP reports the buckets separately:
Usage.total_tokensis documented as "Sum of all token types across session", andmetrics.pyalready says so out loud incache_hit_rate("litellm/OpenAI count cached reads insideprompt_tokens; ACP reports them separately"). So cached reads are not a discountable subset of the input total the way they are on the litellm path. They are additional tokens that were being billed at nothing, which means a cache-heavy gemini-cli session is under-billed, not discounted incorrectly.2. The derived estimate can stack on top of provider cost.
cost_recordedis only set when aUsageUpdatearrives withdelta > 0. Provider cost is cumulative, so a secondUsageUpdatecarrying the same amount producesdelta == 0,cost_recordedstaysFalse, and the token-derived estimate is added on top of cost that was already recorded.accumulated_costthen exceeds the provider's own cumulative figure.3. An unpriced model records $0 in silence.
cost_map.get(model, {})yields empty, both rates are 0, the function returns 0.0, and the caller'sif cost > 0drops it. Nothing is logged, so a whole session reads as free.Summary
_estimate_cost_from_tokenstakes the cache and thought buckets and prices them; cache rates fall back to the plain input rate only when the model has no rate at all (an explicit rate of 0 means free), thought tokens price as output.@cacheon the warn helper keeps it to one line per model, not one per turn).Issue Number
Fixes #4382
How to Test
Unit tests:
Since unit tests alone are not sufficient here, the behaviour is also demonstrated against the real
ACPAgent._record_usagewith real litellm pricing (nothing on the pricing path is mocked), ongemini-2.5-flash:input 3e-07,output 2.5e-06,cache_read 3e-08,cache_creationabsent.Same script,
upstream/mainvs this branch:Scenario 1 going up is the point: the missing 0.000117 is the 900 cache-read, 50 cache-write and 30 thought tokens that were previously free.
Scenario 3's warning:
Full file: 441 passed. Four failures in
TestACPFileSecretMaterialisationandTestACPDataDirIsolationare pre-existing on this Windows host and fail identically on unmodifiedupstream/main.Each of the four behaviours was checked by neutralising it in turn and confirming the matching test goes red: dropping the double-count guard, unpricing cache reads, unpricing thought tokens, and removing the warning. All four are caught.
ruff format --check,ruff checkandscripts/check_import_rules.pyare clean on both touched files.Video/Screenshots
Console output above; this path has no UI surface.
Type
Notes
ed804ffafter review: the cache-rate fallback usedinfo.get(key) or input_cost, which cannot tell a missing rate from a rate of 0. 17 entries in litellm's cost map setcache_read_input_token_costto 0 and 31 setcache_creation_input_token_costto 0 (deepseek/deepseek-chatamong them), so those were charged at the input rate instead of being free. Now adict.getdefault, with a test._estimate_cost_from_tokenskeeps its positional(model, input, output)signature, so existing callers and tests are unaffected._record_usageat all and a pendingUsageUpdatecan be dropped on the nextprepare_usage_sync. That is a lifecycle question rather than a pricing one, and it deserves its own change.