Repository navigation
Conversation
Qwen sessions reported no token usage: the adapter declared no token_calculator, and the sidecar it uses as the transcript holds hook payloads, which carry no token counts. Qwen Code writes the provider's usage on every assistant record of its own chat log, the file the hooks name as transcript_path. At PreToolUse, Stop, StopFailure, SubagentStop and SessionEnd, copy the usage of each assistant record not copied yet into the sidecar as an AssistantUsage record, and add calculate-tokens, which sums those records after --offset. The sidecar stays the only transcript, so a stored checkpoint still reports its tokens where the chat log does not exist. A copy resumes after the furthest copied line while that line still holds the same record, and rereads the log otherwise; the native record uuid keeps a call from being copied or counted twice. Lines still being written, /branch copies, subagent records and history older than the sidecar are skipped. Input excludes the cached share, which is reported as cache reads, and reasoning is counted once whichever provider convention Qwen recorded. Qwen fires Stop without waiting for its write queue, so a turn's last API call can be copied, and counted, one hook late.
…at the upgrade Qwen queues the turn's final assistant record before it fires Stop, but the write can still be in flight when the hook reads the log. While the log ends with a user or tool_result record, or a line still being written, the Stop sync now polls for up to a second, so the TurnEnd checkpoint counts the call instead of the next one. Other hooks still decode only lines that can carry usage. A sidecar that an older adapter started has hook records but no usage copied yet, and its first record dates from the session start, so the first sync after an upgrade copied the session's whole history into that turn. Records now carry v 2, and the floor is the time of the first v 2 record.
Every syncing hook, PreToolUse included, read the whole sidecar to collect the copied UUIDs and the anchor, so each tool call cost more as the session grew. The anchor now comes from a backwards read that stops at the last AssistantUsage record for the log, and the floor from the first v 2 line. The anchor is that last record rather than the copied record with the largest native offset. After Qwen rewrote its log shorter, the largest offset pointed past the new content, so every later sync reread the whole log. Without the copied set, a reread copies calls again; CalculateTokens already counts each native_uuid once, on either side of the offset.
…ng the Stop wait Until a sidecar an older adapter started has a v 2 record, the floor was the running hook's time, so an upgrade between a turn's prompt and its first syncing hook dropped the calls made before that hook. The floor is now the older adapter's last prompt in that case. Qwen rewrites its log by renaming a new file over it. If that happened while Stop waited, the scan kept reading the new file at the old file's position and could skip the final record. A scan now starts over when the log is no longer the same file or has become shorter than its position.
Stop waited only while the log ended with a user or tool_result record, so it gave up when the log still ended with an earlier assistant record: the turn's tool call, or the previous turn's answer, when even the records queued before the final one had not reached the disk. It now waits until the last record is an assistant record that calls no tool and is no older than the sidecar's last UserPromptSubmit after the last copy. Qwen also fires UserPromptSubmit, with an empty prompt, before the call that follows a tool round. The floor for a sidecar an older adapter started now skips those, so an upgrade after a tool round still counts the turn's first call.
…ng prompts The floor for a sidecar an older adapter started was the first v2 record's time. When that record came in the middle of a turn, from a hook that copied nothing yet (a call not on disk, or a hook that does not sync), the calls made since the turn's prompt were left out for good. The floor is now the prompt of the turn the older records leave open, whichever hook runs first. A turn those records saw end stays out, so a first SessionEnd after the upgrade no longer copies the finished turn's calls. The backwards read skipped sidecar lines longer than a block, so a long prompt gave the Stop wait no prompt time. Such a line is now checked from its first 4 KiB, where event and ts are written. A payload without a timestamp got one rounded down to the second, which could put a prompt before the previous turn's answer in the same second. It now keeps sub-second precision.
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
@suhaanthayyil @ashtom tagging you since you wrote and reviewed the Qwen adapter's sidecar. This fixes #75 by copying the usage Qwen already records in its own chat log into the sidecar, so the sidecar stays the only transcript. The fork workflows are waiting for approval to run. Separately, #110 is about Qwen 0.26.7+ not running the adapter's hooks until the folder is trusted. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b2d7ef4e46
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ast prompt Qwen creates its chat log with the first record it writes, through the same queue as the rest, so the log can still be missing when Stop fires. Stop now waits for it as it waits for a record still on its way. With chat recording off, hooks name no log and no sync runs, so this never waits for a log that will not come. The Stop wait took the answer to be no older than the last prompt after the last copy. After a log rewrite moved the anchored line, a sync in the middle of the turn copies the log again, so the previous turn's answer can be the last copy, after the turn's prompt, and Stop took it for the answer. The wait now compares with the sidecar's last prompt, wherever it is, reading backwards past the anchor until it finds one.
Why
Fixes #75. Qwen Code sessions report no token usage in
entire status,checkpoint tokensortokens profile. The adapter declares notoken_calculator, and the sidecar it uses as the transcript holds hook payloads, which carry no token counts.Qwen's own chat log does have them.
ChatRecordingService.recordAssistantTurn(Qwen Code 0.26.9,packages/core/src/services/chatRecordingService.ts) stores the provider's usage asusageMetadataon every assistant record of~/.qwen/projects/<project>/chats/<session>.jsonl, the file every hook names astranscript_path. The OpenAI-compatible pipeline asks for it withstream_options.include_usage: true, and the Anthropic and Responses generators convert their usage to the same shape.@Ar9av reported this and traced it to the sidecar design, and asked whether a second transcript source was wanted. This keeps the sidecar as the only transcript instead, as described below. They offered to implement it, and I've asked on the issue whether they still want to. I'm happy to hand this over or fold in their changes.
What
PreToolUse,Stop,StopFailure,SubagentStopandSessionEnd, the adapter copies the usage of each assistant record it has not copied yet into the sidecar as anAssistantUsagerecord (usage,native_uuid,native_offset).PreToolUseis there so a commit that a tool makes mid-turn sees the calls before it.Stop, the copy first waits up to a second for the turn's final assistant record. Qwen queues that record before it firesStop(client.tsfires Stop afterrecordAssistantTurninllm-chat.ts), but the write can still be in flight. The copy polls until the log's last record (system records ignored) is the answer to the turn's last prompt: an assistant record that calls no tool and is no older than the sidecar's lastUserPromptSubmit. Qwen also firesUserPromptSubmit, with an empty prompt, before the call that follows a tool round. A line still being written is waited for too, and so is a log Qwen hasn't created yet (it creates the log with its first record). So the TurnEnd checkpoint counts that call, even when the log still ends with the turn's tool call or the previous turn's answer. If Qwen replaces the log during the wait (it rewrites by renaming a new file over it) or cuts it shorter, the wait reads it again from the start.v: 2.v: 1records come from the adapter before this change; see the floor below.calculate-tokenssums theAssistantUsagerecords from--offset, a sidecar line index like the adapter's other offsets.token_calculatoris now declared.calculate-tokens, possibly on a machine that has no chat log, socalculate-tokensnever reads the native file.AssistantUsagelines have no top-leveltype, so Entire's compact transcript drops them;extract-summary,extract-promptsandextract-modified-filesignore them.input_tokens=promptTokenCount − cachedContentTokenCount,cache_read_tokens=cachedContentTokenCount. Output counts reasoning once: OpenAI-compatible and Anthropic providers include it incandidatesTokenCount, Gemini only inthoughtsTokenCount, sototalTokenCountdecides, as in Qwen's ownoutputTokenCount(telemetry/gen-ai-usage.ts).Robustness:
AssistantUsagerecord for the log by reading the sidecar backwards from its end, and resumes after that record's line while the line still holds the same record (uuidcheck). A hook therefore reads what was appended since the last copy, not the whole session. Otherwise it rereads the log, because Qwen can rewrite it, for example when prompts are deleted, and copies its calls again. A sidecar line too long to hold, such as a long prompt, is checked from its first 4 KiB, where the sidecar writeseventandts.calculate-tokenscounts eachnative_uuidonce, whether both copies come after--offsetor one comes before it. That covers calls copied again after a rewrite and calls copied by two hooks at the same time. Entire passes the whole session transcript plus an offset for both turn and checkpoint usage, so a copy before a checkpoint boundary is always visible to the next checkpoint./branchcopied from a parent session (forkedFrom), subagent records (isSidechain), and records older than the sidecar's firstv: 2record. The last case covers two kinds of history: a resumed session whose sidecar was cleaned out of the temp directory, and a session whose sidecar the older adapter started. Without the cutoff, that history would land in the first turn after the resume or the upgrade. When the older adapter's records leave a turn open (a typed prompt, not the empty one before a tool round's next call, with no end of turn or session after it), that prompt is the cutoff instead, so an upgrade in the middle of a turn still counts that turn, whichever hook runs first after it. A turn those records saw end stays out.Known limits
PreToolUse; that call was still counted in the same turn, atStop.Stopwaits up to a second for the turn's final call. A call that lands even later is copied at the next hook and counted with that turn or checkpoint. Totals are unaffected.v: 2line comes after all the old lines, so until then each copy scans those old lines to find it.subagents/<session>/agent-<id>.jsonl, and background helpers such as the auto-memory extractor. In the live run below, the extractor made a third request (3011 input tokens) that is not counted. Qwen'sui_telemetryapi_responserecords in the same log do cover it, tagged withsubagent_id, so they could feedsubagent_tokensin a follow-up.promptTokenCount(buildAnthropicUsageMetadata), so they count as input here.Tests (new
tokens_test.go)Driven through
ParseHookagainst a native log the test writes:Stopwaiting for a final record that is half written when the hook firesStopwaiting when the log still ends with the turn's tool call (with and without the tool round's prompt after it) or with the previous turn's answer, also after a long prompt, and when a rewrite made that answer the last copyStopwaiting for a log that doesn't exist yetStopwhile the log already holds an answer, which is counted with the next turn and leaves the session total rightStopwaits, read again from the startforkedFromandisSidechainrecords, which are skipped--offsetAlso: the usage mapping for OpenAI-style, Gemini-style, no-total and clamped cache;
"{}"(what the compliance suite sends) and pre-change sidecars report zeros without error; oversized lines are skipped without losing offsets.go test ./...andgo test -race ./...pass for the qwen module.gofmt -l .is clean, and golangci-lint 2.11.3 (the CI pin) reports 0 issues. Onmainthe new tests don't compile (CalculateTokensdoesn't exist), and themainbinary reportstoken_calculator: falseandunknown subcommand: calculate-tokens.Live check
I installed
@qwen-code/qwen-code@0.26.9from npm and ranqwen -p "Create hello.txt containing hello" --yoloagainst a local OpenAI-compatible mock. The mock streams a usage chunk, and gives each request its own numbers. The adapter's installed hooks ran through a stand-inentirethat passes each payload toparse-hook.stream_options.include_usage: trueand recordedusageMetadataon both main-chat assistant records, with the mock's exact numbers:{"promptTokenCount":1011,"candidatesTokenCount":13,"cachedContentTokenCount":100,...}and{"promptTokenCount":2011,"candidatesTokenCount":23,"cachedContentTokenCount":200,...}.AssistantUsagerecord per call, andcalculate-tokens --offset 0printed{"input_tokens":2722,"cache_creation_tokens":0,"cache_read_tokens":300,"output_tokens":36,"api_call_count":2}.PreToolUse, thenStop). In the other run, the first call wasn't on disk yet atPreToolUseand was copied atStop.Stopwait andv: 2, two with the backwards anchor read, three with the answer-basedStopwait, two with b2d7ef4 and one with 3de67be. Each time, the final record was already on disk whenStopfired. Replaying a run'sStopwith the answer-based wait returned in 5 ms, so it doesn't wait when the answer is there. In every run I checked for it, Qwen firedUserPromptSubmita second time, with an empty prompt, before the call after the tool round.Not verified
QWEN_E2E=1), which needs real Qwen credentials andentire.{}with--offset 0, which is covered by a unit test and by running the binary..qwen/settings.json) only in a trusted folder, or if they were approved interactively before. Otherwise it prints "Project hooks in .qwen/settings.json were not loaded: the folder is not explicitly trusted", and no Entire hook runs. That affects the adapter as a whole, not just tokens, so it has its own issue, qwen: Qwen Code 0.26.7+ skips the adapter's hooks until the user trusts them #110.