|
2 | 2 | // |
3 | 3 | // Every engine moshcode wraps already writes down what it used — Claude Code |
4 | 4 | // 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 |
6 | 7 | // opencode stores a per-message `cost` it computed itself in SQLite. Nobody has |
7 | 8 | // to be instrumented and nothing has to be proxied: the numbers are on disk |
8 | 9 | // because the CLI put them there. This module reads them, normalises them into |
@@ -408,6 +409,123 @@ async function opencodeRuns(engine, { since, cwd } = {}) { |
408 | 409 | return [...bySession.values()]; |
409 | 410 | } |
410 | 411 |
|
| 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 | + |
411 | 529 | // --------------------------------------------------------------------------- |
412 | 530 | // aider — it prints the running total into its own chat history |
413 | 531 | // --------------------------------------------------------------------------- |
@@ -486,11 +604,12 @@ export const COST_READERS = { |
486 | 604 | codex: (opts) => codexRuns(opts), |
487 | 605 | opencode: (opts) => opencodeRuns("opencode", opts), |
488 | 606 | privacycode: (opts) => opencodeRuns("privacycode", opts), |
| 607 | + qwen: (opts) => qwenRuns(opts), |
489 | 608 | aider: (opts) => aiderRuns(opts), |
490 | 609 | }; |
491 | 610 |
|
492 | 611 | /** 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"]; |
494 | 613 |
|
495 | 614 | /** |
496 | 615 | * Finish a run: price it, and record where the price came from. |
|
0 commit comments