feat(swarm): use real provider tokens for SwarmRun totals#94
Merged
warren618 merged 1 commit intoMay 11, 2026
Merged
Conversation
Symptom ------- SwarmRun.total_input_tokens / total_output_tokens are character-count guesses (`len(json.dumps(messages)) // 4` for input, `len(content) // 4` for output). Worker.py:_estimate_tokens has a docstring that *claims* to read LangChain's usage_metadata first, but the body never actually does — the response object that reaches it (LLMResponse) drops the metadata in ChatLLM._parse_response. The real-world cost of the bug is sneaky: * English prompts: char/4 over-counts the input tokens by ~20-30% on function-calling traffic (the JSON envelope inflates the divisor). * CJK / Thai prompts: char/4 *under-counts* by 50-70% because each Chinese / Thai character is one or two real tokens, not 0.25. * The reported totals feed cost-audit code (now landing in #92), cross-provider comparison, and any "did this run cost what we expected" sanity check. All of those silently disagree with the provider's billing. Why prompt-only fixes are not enough ------------------------------------ Adding a "use real tokens" rule to the worker prompt is a category error — the issue is that the data is already on every AIMessage from LangChain, we just throw it away two layers down. So the structural fix is to keep it. Fix --- 1. **`agent/src/providers/chat.py`** — give `LLMResponse` an `usage_metadata: dict[str, int] | None` field (default `None` for backward compatibility) and forward `ai_message.usage_metadata` verbatim from `_parse_response`. Streaming path inherits the merge automatically because `AIMessageChunk.__add__` already accumulates usage on the final aggregate. We also normalise non-`dict` shapes (some LangChain versions return a `UsageMetadata` TypedDict) to a plain `dict[str, int]` so the value is JSON-friendly. 2. **`agent/src/swarm/worker.py`** — `_estimate_tokens` now prefers `response.usage_metadata` when present and non-zero, and only falls back to the prior char-count heuristic when the provider didn't report usage. Existing call sites (per-iteration accumulation in `runtime._run_worker_with_retries`) are unchanged — they sum the per-call totals exactly as before, those numbers are just real now. Backward compatibility ---------------------- - `LLMResponse.usage_metadata` defaults to `None`; any caller that builds the dataclass directly (incl. tests) keeps working. - `_estimate_tokens` still returns a `(int, int)` tuple. Behaviour is unchanged for providers / mock paths that don't carry usage metadata. - The persisted `total_input_tokens` / `total_output_tokens` schema on `SwarmRun` is unchanged; only the *values* improve. Tests ----- `agent/tests/test_swarm_token_tracking.py` — 12 unit tests covering both layers: * `ChatLLM._parse_response`: dict propagation, TypedDict-like normalisation to plain dict, `None` preservation when the provider omits usage, defensive degrade-to-`None` for unconvertible objects. * `worker._estimate_tokens`: prefers real counts even when they would contradict a wildly-large heuristic input; falls back when usage is `None`; falls back when usage is `{0, 0}` to avoid pinning totals at zero; partial usage (input only) is taken verbatim instead of mixed with heuristic; non-`LLMResponse` callers degrade safely; extra fields (`total_tokens`, `input_token_details`, etc.) don't disturb the read. Adjacent suites kept green: `test_llm.py`, `test_kimi_reasoning_content.py`, `test_models.py`, full sweep is at the same baseline pass count plus the 12 new tests, with the same three pre-existing Windows-specific failures untouched (those are addressed in #88).
5f310b5 to
86d43b0
Compare
Contributor
Author
|
Rebased onto current Re-ran the unit suite (12/12 pass in 0.78s) and a fresh end-to-end smoke against The +23% input over-count on this tiny English prompt matches the bias range described in the PR body (20-30% over-count for English with JSON envelope). With the PR applied, totals reflect the provider-reported numbers; the heuristic only kicks in when |
Collaborator
|
Thanks, merged. Using provider-reported usage makes swarm token accounting more accurate while keeping the existing fallback path. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
SwarmRun.total_input_tokens/total_output_tokensare currently character-count guesses (len(json.dumps(messages)) // 4). LangChain already attaches real provider tokens on everyAIMessage.usage_metadata— the swarm just throws them away in_parse_response. This PR keeps the metadata and uses it.chat.py,worker.py), one new test file (12 tests). Backward-compatible:LLMResponse.usage_metadatadefaults toNone; the existing char-count fallback runs when the provider doesn't return usage.provider/modelon the run): with both PRs merged,run.jsonhas enough information for an actual cost audit instead of a rough multiplier.Why
worker.py:_estimate_tokenscarries a docstring that says it triesusage_metadatafirst, but the body never actually does — the response object that reaches it (LLMResponse) drops the field inChatLLM._parse_response. So every per-iteration token accumulation inruntime._run_worker_with_retries, and therefore everySwarmRun.total_*_tokenswritten torun.json, is a heuristic.The real-world cost is asymmetric and quietly bad:
reasoning_contentis incontentor splitAnything that looks at those totals — a cost dashboard, side-by-side provider comparison, "did this run cost what we expected" sanity checks — silently disagrees with the provider's billing. Adding a "use real tokens" line to the worker prompt is a category error; the data is already on every
AIMessage, we just need to keep it.What changes
agent/src/providers/chat.pyLLMResponsegets anusage_metadata: dict[str, int] | Nonefield with a default ofNone._parse_responseforwardsai_message.usage_metadataverbatim. The streaming path inherits the merge automatically —AIMessageChunk.__add__already accumulates usage onto the final aggregate, so the existingfor chunk in llm.stream(...)loop instream_chatproduces an aggregate with real totals on the last chunk.Some LangChain versions surface a
UsageMetadataTypedDict that doesn't always JSON-serialise without a cast; we normalise to a plaindict[str, int]so the value can be persisted alongside the rest of the run state without surprises.agent/src/swarm/worker.py_estimate_tokensnow prefersresponse.usage_metadatawhen present and at least one ofinput_tokens/output_tokensis non-zero. Falls back to the existinglen // 4heuristic only when the provider didn't return usage data — keeps the behaviour contract for legacy / partial responses while making per-run totals real for every modern provider (OpenAI, Anthropic, Gemini, Moonshot, DeepSeek, Z.ai, OpenRouter all returnusage_metadata).Existing accumulation in
runtime._run_worker_with_retries:507-508is unchanged — it sums the per-call tuple exactly as before, the values are just real now.Backward compatibility
LLMResponse.usage_metadatadefaults toNone; any caller that builds the dataclass directly (incl. tests) keeps working without modification._estimate_tokensstill returns a(int, int)tuple with the same call signature.SwarmRun.total_input_tokens/total_output_tokensfield shape is unchanged — only the values improve.run.jsonfiles are untouched (the schema didn't change).Tests
agent/tests/test_swarm_token_tracking.py— 12 unit tests:ChatLLM._parse_responsepropagation (4 tests)usage_metadataflows verbatim ontoLLMResponse.usage_metadata.Noneis preserved when the provider omits usage.object()) degrade toNonerather than crashing the parser.worker._estimate_tokensselection (8 tests)usage_metadataisNone.usage_metadatais{0, 0}(treats "all zero" as "no real data" so totals don't pin to zero on broken provider responses).input_tokensonly) is taken verbatim — input set, output 0 — better than silently mixing real + heuristic.LLMResponseobjects (older callers, mocks) degrade safely to the legacy heuristic.total_tokens,input_token_details,output_token_details) are ignored and don't disturb the read.Test plan
pytest agent/tests/test_swarm_token_tracking.py -v— 12/12 pass.pytest agent/tests/test_llm.py agent/tests/test_kimi_reasoning_content.py -v— 46/46 pass (no regression in the chat layer).pytest --ignore=agent/tests/e2e_backtest --tb=line -q— full sweep: same baseline + 12 new tests, no regressions. The same three pre-existing Windows-specific failures remain (separately addressed in #88).Out of scope
run.jsonnow carries them; UI rendering can land later.events.jsonl— this PR only fixes the per-run aggregate. Atask_completedevent payload extension is straightforward follow-up.provider,model, and real tokens are all on the run, and aLLM_PRICINGtable fromllm_providers.jsonwould close the loop.Checklist
agent/src/agent/,agent/src/session/).CONTRIBUTING.md(Conventional Commit prefix, Google-style docstrings, regression test added).usage_metadatais opt-in via a defaulted dataclass field).