Skip to content

Commit e8bbb91

Browse files
ralyodioclaude
andauthored
cost: read qwen's usage log (#420)
qwen was listed as an engine that keeps no readable record, and that has stopped being true: it appends one record per request to ~/.qwen/usage/token-usage-YYYY-MM.jsonl, and writes the directory it was started in to ~/.qwen/projects/<slug>/chats/<session>.runtime.json. So a qwen session launched through /agents showed up nowhere in /usage. Two pieces of its arithmetic are easy to get wrong, and both are checked against qwen-code's own conversion rather than guessed: - `cachedTokens` is part of `inputTokens`, so fresh input is the difference — the same trap codex's cumulative counts have. - `thoughtsTokens` is already inside `outputTokens` on the OpenAI-compatible path (`completion_tokens` contains `reasoning_tokens`), so adding it would double-count the thinking. Only the native path, which reports Gemini's `candidatesTokenCount`, has to add it back. No Alibaba rates are shipped — that stays deliberate — so qwen runs report tokens and no cost, and the report already says how to price them. Verified against this machine's real log: 314,485 in / 54,574 out / 8.34M cached across 3 sessions, matching a raw sum of the file. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6689a8e commit e8bbb91

3 files changed

Lines changed: 196 additions & 3 deletions

File tree

src/cli-schema.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export const CORE_CLI_COMMANDS = [
139139
+ "codex's rollout token counts, opencode's per-message cost, aider's chat history. a figure marked "
140140
+ "`~` was worked out from published rates and is what the tokens WOULD cost on the api; unmarked "
141141
+ "figures are the engine's own arithmetic. models with no rate show tokens and no cost — add yours "
142-
+ "to ~/.moshcode/pricing.json. gemini, kimi, qwen, deepseek and openagents log nothing readable, "
142+
+ "to ~/.moshcode/pricing.json. gemini, kimi, deepseek and openagents log nothing readable, "
143143
+ "so they report no cost rather than zero.",
144144
},
145145
// Everyone who has used a coding agent's own `/usage` types that word first,

src/cost.mjs

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
//
33
// Every engine moshcode wraps already writes down what it used — Claude Code
44
// keeps a per-message `usage` block in ~/.claude/projects/**/<session>.jsonl,
5-
// Codex emits cumulative `token_count` events into ~/.codex/sessions/…, and
5+
// Codex emits cumulative `token_count` events into ~/.codex/sessions/…, qwen
6+
// appends a record per request to ~/.qwen/usage/token-usage-YYYY-MM.jsonl, and
67
// opencode stores a per-message `cost` it computed itself in SQLite. Nobody has
78
// to be instrumented and nothing has to be proxied: the numbers are on disk
89
// because the CLI put them there. This module reads them, normalises them into
@@ -408,6 +409,123 @@ async function opencodeRuns(engine, { since, cwd } = {}) {
408409
return [...bySession.values()];
409410
}
410411

412+
// ---------------------------------------------------------------------------
413+
// qwen — ~/.qwen/usage/token-usage-YYYY-MM.jsonl, one record per request
414+
// ---------------------------------------------------------------------------
415+
416+
const qwenUsageDir = () => path.join(home(), ".qwen", "usage");
417+
const qwenProjectsDir = () => path.join(home(), ".qwen", "projects");
418+
419+
/** Only the monthly usage logs; the directory also holds unrelated state. */
420+
const QWEN_USAGE_FILE = /^token-usage-\d{4}-\d{2}\.jsonl$/;
421+
422+
/**
423+
* sessionId → the directory qwen was started in.
424+
*
425+
* The usage log records no path, so on its own it can say what was spent but
426+
* not where. qwen writes a tiny `<sessionId>.runtime.json` beside each chat
427+
* with the `work_dir` in it, and that is the only exact answer on disk — the
428+
* project directory those chats sit in is a lossy dash-slug of the same path,
429+
* so it can group sessions but cannot be turned back into a directory.
430+
*/
431+
function qwenSessionDirs() {
432+
const dirs = new Map();
433+
const root = qwenProjectsDir();
434+
for (const project of listDir(root)) {
435+
if (!project.isDirectory()) continue;
436+
const chats = path.join(root, project.name, "chats");
437+
for (const entry of listDir(chats)) {
438+
if (!entry.isFile() || !entry.name.endsWith(".runtime.json")) continue;
439+
let meta;
440+
try { meta = parseJson(fs.readFileSync(path.join(chats, entry.name), "utf8")); }
441+
catch { continue; }
442+
if (meta?.session_id && meta.work_dir) dirs.set(String(meta.session_id), String(meta.work_dir));
443+
}
444+
}
445+
return dirs;
446+
}
447+
448+
/**
449+
* One usage record → the shape every engine normalises into.
450+
*
451+
* `inputTokens` is the whole prompt and `cachedTokens` is the part of it that
452+
* was a cache hit, so the fresh input is the difference — counting both would
453+
* bill the cache twice at the full input rate.
454+
*
455+
* `thoughtsTokens` is the subtle one. On the OpenAI-compatible path — which is
456+
* what `authType: "openai"` marks, and what qwen's own DashScope endpoint uses
457+
* — `outputTokens` is `completion_tokens`, which already *contains* the
458+
* reasoning tokens, and adding them would double-count the thinking. The native
459+
* path reports Gemini's `candidatesTokenCount`, which excludes them, so that is
460+
* the only case where they have to be added back.
461+
*/
462+
function qwenUsageOf(record) {
463+
const cached = num(record.cachedTokens);
464+
const thoughts = num(record.thoughtsTokens);
465+
return {
466+
input: Math.max(0, num(record.inputTokens) - cached),
467+
output: num(record.outputTokens) + (record.authType === "openai" ? 0 : thoughts),
468+
cacheRead: cached,
469+
cacheWrite5m: 0,
470+
cacheWrite1h: 0,
471+
};
472+
}
473+
474+
/**
475+
* Every qwen session in the window, one run per session.
476+
*
477+
* The log is per-request and flat, so the session id is what makes a run: a
478+
* subagent's requests carry the same one (`source` names the subagent) and
479+
* belong to the session that spawned them.
480+
*/
481+
export function qwenRuns({ since, cwd } = {}) {
482+
const dirs = qwenSessionDirs();
483+
const sessions = new Map();
484+
485+
for (const entry of listDir(qwenUsageDir())) {
486+
if (!entry.isFile() || !QWEN_USAGE_FILE.test(entry.name)) continue;
487+
const file = path.join(qwenUsageDir(), entry.name);
488+
const stat = safeStat(file);
489+
// A month untouched since before the window holds nothing inside it.
490+
if (!stat || (since != null && stat.mtimeMs < since)) continue;
491+
492+
let text;
493+
try { text = fs.readFileSync(file, "utf8"); } catch { continue; }
494+
for (const line of text.split("\n")) {
495+
if (!line || line.charCodeAt(0) !== 123) continue; // fast reject: not "{"
496+
const record = parseJson(line);
497+
if (!record?.sessionId) continue;
498+
const at = stamp(record.timestamp);
499+
if (at != null && since != null && at < since) continue;
500+
501+
const id = String(record.sessionId);
502+
const where = dirs.get(id) || "";
503+
// A session whose directory is unknown cannot be claimed for the one that
504+
// was asked for — reporting it there would invent an attribution.
505+
if (cwd && !samePath(where, cwd)) continue;
506+
507+
let run = sessions.get(id);
508+
if (!run) {
509+
run = {
510+
engine: "qwen", id, cwd: where,
511+
usage: { ...EMPTY_USAGE }, byModel: new Map(),
512+
start: null, end: null, engineCost: null,
513+
};
514+
sessions.set(id, run);
515+
}
516+
const one = qwenUsageOf(record);
517+
const model = record.model || "unknown";
518+
run.usage = addUsage(run.usage, one);
519+
run.byModel.set(model, addUsage(run.byModel.get(model) || EMPTY_USAGE, one));
520+
if (at != null) {
521+
run.start = run.start == null ? at : Math.min(run.start, at);
522+
run.end = run.end == null ? at : Math.max(run.end, at);
523+
}
524+
}
525+
}
526+
return [...sessions.values()];
527+
}
528+
411529
// ---------------------------------------------------------------------------
412530
// aider — it prints the running total into its own chat history
413531
// ---------------------------------------------------------------------------
@@ -486,11 +604,12 @@ export const COST_READERS = {
486604
codex: (opts) => codexRuns(opts),
487605
opencode: (opts) => opencodeRuns("opencode", opts),
488606
privacycode: (opts) => opencodeRuns("privacycode", opts),
607+
qwen: (opts) => qwenRuns(opts),
489608
aider: (opts) => aiderRuns(opts),
490609
};
491610

492611
/** Engines moshcode can launch but cannot cost — named so the report can say so. */
493-
export const UNCOSTED_ENGINES = ["gemini", "kimi", "qwen", "deepseek", "openagents"];
612+
export const UNCOSTED_ENGINES = ["gemini", "kimi", "deepseek", "openagents"];
494613

495614
/**
496615
* Finish a run: price it, and record where the price came from.

test/cost.test.mjs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,80 @@ test("codex rollouts", async (t) => {
220220
}));
221221
});
222222

223+
test("qwen usage log", async (t) => {
224+
const record = ({ at, session = "qw-1", model = "qwen3.8-max", authType = "openai", source = "main", input, output, cached = 0, thoughts = 0 }) => JSON.stringify({
225+
schemaVersion: 1, id: `${session}-${at}-${source}`, timestamp: at, sessionId: session,
226+
model, authType, source,
227+
inputTokens: input, outputTokens: output, cachedTokens: cached, thoughtsTokens: thoughts,
228+
totalTokens: input + output,
229+
});
230+
231+
const monthFile = (home, at) => path.join(home, ".qwen", "usage", `token-usage-${at.slice(0, 7)}.jsonl`);
232+
233+
const runtime = (home, { slug, session, workDir }) => write(
234+
path.join(home, ".qwen", "projects", slug, "chats", `${session}.runtime.json`),
235+
JSON.stringify({ schema_version: 1, session_id: session, work_dir: workDir }),
236+
);
237+
238+
await t.test("sums a session's requests and nets the cached prompt out of input", () => withHome(async (home) => {
239+
const at = new Date().toISOString();
240+
runtime(home, { slug: "-home-anthony-src-api", session: "qw-1", workDir: "/home/anthony/src/api" });
241+
write(monthFile(home, at), [
242+
record({ at, input: 1000, output: 200, cached: 600, thoughts: 120 }),
243+
// A subagent's requests carry the session that spawned them.
244+
record({ at, source: "Explore", input: 500, output: 50, cached: 0 }),
245+
].join("\n"));
246+
247+
const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["qwen"] });
248+
assert.equal(run.engine, "qwen");
249+
assert.equal(run.id, "qw-1");
250+
assert.equal(run.cwd, "/home/anthony/src/api");
251+
assert.equal(run.usage.input, 900); // (1000 - 600) + 500
252+
assert.equal(run.usage.cacheRead, 600);
253+
// The OpenAI-compatible path counts reasoning inside `outputTokens`, so the
254+
// 120 thinking tokens are already in the 200 and must not be added again.
255+
assert.equal(run.usage.output, 250);
256+
assert.deepEqual(run.models, ["qwen3.8-max"]);
257+
// Alibaba rates are deliberately not shipped, so tokens stand and cost does not.
258+
assert.equal(run.cost, null);
259+
assert.deepEqual(run.unpriced, ["qwen3.8-max"]);
260+
}));
261+
262+
await t.test("the native path reports thinking separately, so it is added back", () => withHome(async (home) => {
263+
const at = new Date().toISOString();
264+
write(monthFile(home, at), record({ at, authType: "qwen-oauth", input: 100, output: 40, thoughts: 60 }));
265+
const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["qwen"] });
266+
assert.equal(run.usage.output, 100);
267+
}));
268+
269+
await t.test("a session in another directory is not this directory's cost", () => withHome(async (home) => {
270+
const at = new Date().toISOString();
271+
runtime(home, { slug: "-home-anthony-src-web", session: "qw-1", workDir: "/home/anthony/src/web" });
272+
write(monthFile(home, at), record({ at, input: 10, output: 1 }));
273+
const runs = await engineRuns({ since: Date.now() - 3600e3, engines: ["qwen"], cwd: "/home/anthony/src/api" });
274+
assert.deepEqual(runs, []);
275+
}));
276+
277+
await t.test("requests older than the window do not count", () => withHome(async (home) => {
278+
const old = new Date(Date.now() - 48 * 3600e3).toISOString();
279+
const now = new Date().toISOString();
280+
write(monthFile(home, now), [
281+
record({ at: old, input: 999, output: 999 }),
282+
record({ at: now, input: 10, output: 2 }),
283+
].join("\n"));
284+
const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["qwen"] });
285+
assert.equal(run.usage.input, 10);
286+
assert.equal(run.usage.output, 2);
287+
}));
288+
289+
await t.test("a session with no runtime file still reports, with no directory", () => withHome(async (home) => {
290+
const at = new Date().toISOString();
291+
write(monthFile(home, at), record({ at, input: 10, output: 2 }));
292+
const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["qwen"] });
293+
assert.equal(run.cwd, "");
294+
}));
295+
});
296+
223297
test("aider history", async (t) => {
224298
const history = [
225299
"# aider chat started at 2026-08-16 09:00:00",

0 commit comments

Comments
 (0)