UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

280 lines (279 loc) • 11.3 kB
/** * Reads token usage out of Qwen Code's own session transcripts. * * Layout, confirmed on a real machine: `~/.qwen/projects/<sanitized-cwd>/chats/ * <sessionId>.jsonl`, where the sanitized cwd is the absolute working-directory * path with every `/` replaced by `-` — the same convention Claude Code uses * for its own `~/.claude/projects` layout, which is unsurprising since Qwen * Code's chat-recording model traces back to the same lineage as Gemini CLI's. * `~/.qwen/projects/<slug>/` also holds a sibling `memory/` directory with a * `MEMORY.md` file and no JSONL, so the walk below is scoped to `chats/` * specifically rather than the whole project directory. * * The bundled CLI (`@qwen-code/qwen-code`, installed copy inspected directly) * references `parentSessionId` the same way Gemini CLI's engine does for * subagent transcripts, so this reader recurses under each project's `chats/` * directory at any depth rather than globbing one level — the same lesson * `claudeCodeReader.ts` already paid for. * * Each line is a flat record with `type`, `uuid`, and (for `type: "assistant"`) * a `model` and `usageMetadata` object shaped like Google's GenAI * `usageMetadata`: `promptTokenCount`, `candidatesTokenCount`, * `thoughtsTokenCount`, `totalTokenCount`, `cachedContentTokenCount`. On a real * machine, `totalTokenCount` equalled `promptTokenCount + candidatesTokenCount * + thoughtsTokenCount` exactly across all 21 real assistant records sampled — * so `thoughtsTokenCount` is additive into output, not a separate bucket. * * `cachedContentTokenCount` is a SUBSET of `promptTokenCount`, not disjoint — * confirmed by reading the installed CLI's own converters rather than * inferring from samples, because every real sample on this machine had zero * cache tokens. `buildAnthropicUsageMetadata` in * `chunks/anthropicContentGenerator-*.js` and the OpenAI-compatible converter * in `chunks/chunk-*.js` both derive `cachedContentTokenCount` from the * upstream response's own cache-read field and leave `promptTokenCount` as the * upstream's full prompt count — the cached portion is never added on top. So * `inputTokens` here is `prompt - cached`, matching the convention this * subsystem's `LocalUsageTotals` type expects (`inputTokens` + * `cacheReadTokens` must sum to the true prompt size without double-counting), * the same subtraction `codexReader.ts` does for the same reason. * * Cost is deliberately `unavailable`. Two independent reasons, either one * sufficient on its own: (1) no provider identifier is logged alongside the * model name, and Qwen Code is pluggable to arbitrary OpenAI- or * Anthropic-compatible backends — on the machine this was written against, the * model names logged were `claude-sonnet-4-5` and `claude-haiku-4-5`, i.e. this * particular install was routed through a custom/litellm endpoint rather than * Alibaba's own Qwen models, so even the model name does not reliably identify * a vendor to price against; (2) Qwen Code's own hosted models are commonly * used via a subscription/OAuth flow rather than metered API keys, the same * situation that keeps Codex's confidence `unavailable`. */ import { createReadStream } from "fs"; import { createInterface } from "readline"; import { readdir, stat } from "fs/promises"; import { homedir } from "os"; import { join } from "path"; import { resolveScanCutoffMs } from "./scanWindow.js"; const CLI_ID = "qwen-code"; function projectsRoot() { return join(homedir(), ".qwen", "projects"); } function emptyTotals() { return { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: 0, costConfidence: "unavailable", unpricedRequests: 0, unpricedModels: [], }; } function num(value) { return typeof value === "number" && Number.isFinite(value) ? value : 0; } /** * A scan that cannot read a directory must say so. * * Swallowing every readdir failure made an unreadable tree indistinguishable * from an empty one: the report showed zero usage, no failure entry, and an * operator with a permissions problem had nothing to look at. ENOENT stays * silent because a missing root legitimately means "nothing recorded yet"; * anything else is a real failure and is surfaced. */ function isMissing(error) { return (typeof error === "object" && error !== null && error.code === "ENOENT"); } /** Every `chats/` directory, one per project, under the Qwen projects root. */ async function collectChatsDirs(root, errors) { let entries; try { entries = await readdir(root, { withFileTypes: true }); } catch (error) { if (!isMissing(error)) { errors.push({ cliId: CLI_ID, filePath: root, message: error instanceof Error ? error.message : String(error), }); } return []; } const dirs = []; for (const entry of entries) { if (!entry.isDirectory()) { continue; } const chatsDir = join(root, entry.name, "chats"); try { const info = await stat(chatsDir); if (info.isDirectory()) { dirs.push(chatsDir); } } catch { // No `chats/` for this project — nothing recorded yet. } } return dirs; } /** Every `.jsonl` transcript at any depth under a `chats/` directory. */ async function collectTranscripts(dir, out, errors) { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { if (!isMissing(error)) { errors.push({ cliId: CLI_ID, filePath: dir, message: error instanceof Error ? error.message : String(error), }); } return; } for (const entry of entries) { const full = join(dir, entry.name); if (entry.isDirectory()) { await collectTranscripts(full, out, errors); } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { out.push(full); } } } /** * Dedup is by `uuid`, keeping the LARGEST total-token count seen for that id * — the same "resumed session re-logs a turn" protection `claudeCodeReader.ts` * uses, applied here on the off chance a resumed Qwen session does the same. * The map is per-file and discarded after, so it stays bounded regardless of * how many files a scan covers. */ async function foldTranscript(filePath, totals, unpriced) { const seen = new Map(); const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }), crlfDelay: Infinity, }); try { for await (const line of rl) { if (!line || line.charCodeAt(0) !== 123 /* '{' */) { continue; } let parsed; try { parsed = JSON.parse(line); } catch { // A transcript being appended to while we read it ends in a partial // line. That is normal, not corruption — skip it. continue; } const record = parsed; if (record.type !== "assistant" || !record.usageMetadata) { continue; } const id = record.uuid; if (typeof id !== "string" || id.length === 0) { continue; } const usage = record.usageMetadata; const prompt = num(usage.promptTokenCount); const cached = num(usage.cachedContentTokenCount); const candidate = { model: record.model ?? "unknown", input: Math.max(0, prompt - cached), output: num(usage.candidatesTokenCount) + num(usage.thoughtsTokenCount), cached, }; const existing = seen.get(id); // Cached tokens are part of the raw prompt, so a record can carry more // total usage and still lose on input+output alone — keep-max would then // keep the smaller of two duplicates. const candidateTotal = candidate.input + candidate.output + candidate.cached; const existingTotal = existing ? existing.input + existing.output + existing.cached : -1; if (!existing || candidateTotal > existingTotal) { seen.set(id, candidate); } } } finally { rl.close(); } for (const turn of seen.values()) { totals.requests += 1; totals.inputTokens += turn.input; totals.outputTokens += turn.output; totals.cacheReadTokens += turn.cached; // Cost is unavailable for this reader (see module header), so every turn // is unpriced by construction rather than by a failed lookup. totals.unpricedRequests += 1; unpriced.add(turn.model); } } export async function createQwenCodeReader() { return { descriptor: { id: CLI_ID, displayName: "Qwen Code", verified: true, dedupStrategy: "message-id-keep-max", costConfidence: "unavailable", requiresSqlite: false, }, detect: async () => { try { const info = await stat(projectsRoot()); return info.isDirectory(); } catch { return false; } }, scan: async (options) => { const totals = emptyTotals(); const errors = []; const unpriced = new Set(); const chatsDirs = await collectChatsDirs(projectsRoot(), errors); const files = []; for (const dir of chatsDirs) { await collectTranscripts(dir, files, errors); } // Only Infinity means "no time filter" — see scanWindow.ts. A cutoff of // `undefined` reads everything; any finite number is an mtime floor. const cutoff = resolveScanCutoffMs(options?.sinceDays); let filesScanned = 0; for (const file of files) { try { if (cutoff !== undefined) { const info = await stat(file); if (info.mtimeMs < cutoff) { continue; } } await foldTranscript(file, totals, unpriced); filesScanned += 1; } catch (error) { // One unreadable transcript is a reportable fact, not a reason to // lose the totals from every other file in the scan. errors.push({ cliId: CLI_ID, filePath: file, message: error instanceof Error ? error.message : String(error), }); } } totals.unpricedModels = [...unpriced].sort(); return { cliId: CLI_ID, totals, filesScanned, errors }; }, }; }