diff --git a/docs/design/issue-4479-token-usage-stats-coordination.md b/docs/design/issue-4479-token-usage-stats-coordination.md new file mode 100644 index 00000000000..3b270d1bcc6 --- /dev/null +++ b/docs/design/issue-4479-token-usage-stats-coordination.md @@ -0,0 +1,46 @@ +# Issue #4479 token usage stats coordination + +## Context + +Issue #4479 asks for daily Qwen Code token-consumption visibility. The scope was +clarified in the issue thread to prefer a CLI command, export support, monthly +summaries, and per-model token consumption. A maintainer comment also called out +coordination with adjacent statistics work: + +- #4252: generation timing metrics in `/stats` such as TTFT, generation duration, + and TPS. +- #4182: content-free session-scale counters for memory diagnostics. + +## Coordination decisions + +1. **Use `/stats`, not a new top-level command.** + Token usage is exposed as `/stats daily`, `/stats monthly`, and + `/stats export` so it shares the existing statistics command surface with + session stats and future generation metrics. + +2. **Persist token counters as local JSONL.** + Each API response appends one content-free record to + `usage/token-usage-YYYY-MM.jsonl` under the runtime directory. This satisfies + daily/monthly aggregation without adding SQLite as a new dependency. + +3. **Keep #4252 timing semantics separate.** + Token usage summaries may include `apiDurationMs`, which is the existing + end-to-end API response duration from telemetry. It is deliberately named as + API duration and must not be presented as generation duration, TTFT, or TPS. + #4252 remains the owner for generation timing metrics. + +4. **Keep #4182 privacy and memory-diagnostic boundaries.** + Usage records store aggregate counters and stable dimensions only: local date, + month, session id, model, auth type, source, token counters, and API duration. + They do not store prompt text, response text, tool content, project paths, + prompt ids, or response ids. + +5. **Export remains aggregate-only.** + CSV and JSON exports are summaries, not raw transcript exports. They group by + total, model, auth type, model/auth type, and source. + +## Non-goals + +- Do not implement #4252's TTFT/TPS/generation-duration instrumentation here. +- Do not extend `/doctor memory` or implement #4182 in this change. +- Do not add a separate token-usage top-level slash command. diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 627cc379f20..52526452887 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -489,12 +489,57 @@ export default { 'Auto Edit': 'Edició automàtica', YOLO: 'YOLO', 'toggle vim mode on/off': 'activar/desactivar el mode Vim', - 'check session stats. Usage: /stats [model|tools]': - 'comprovar les estadístiques de la sessió. Ús: /stats [model|tools]', 'Show model-specific usage statistics.': "Mostrar les estadístiques d'ús específiques del model.", 'Show tool-specific usage statistics.': "Mostrar les estadístiques d'ús específiques de les eines.", + 'Show daily token usage statistics.': + "Mostrar les estadístiques diàries d'ús de tokens.", + 'Show monthly token usage statistics.': + "Mostrar les estadístiques mensuals d'ús de tokens.", + 'Export token usage statistics to CSV or JSON.': + "Exportar les estadístiques d'ús de tokens a CSV o JSON.", + 'No usage data.': "No hi ha dades d'ús.", + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}}: {{tokens}} tokens ({{requests}} sol·licituds)', + 'Daily token usage for {{value}}': 'Ús diari de tokens per a {{value}}', + 'Monthly token usage for {{value}}': 'Ús mensual de tokens per a {{value}}', + 'Total: {{tokens}} tokens': 'Total: {{tokens}} tokens', + 'Requests: {{requests}}': 'Sol·licituds: {{requests}}', + 'Breakdown:': 'Desglossament:', + 'Input: {{tokens}}': 'Entrada: {{tokens}}', + 'Output: {{tokens}}': 'Sortida: {{tokens}}', + 'Cached (included in Input): {{tokens}}': + 'Memòria cau (inclosa a l’entrada): {{tokens}}', + 'Thoughts: {{tokens}}': 'Raonament: {{tokens}}', + 'By model:': 'Per model:', + 'By auth type:': "Per tipus d'autenticació:", + 'By model/auth type:': "Per model/tipus d'autenticació:", + 'By source:': 'Per origen:', + 'Failed to load token usage stats: {{error}}': + "No s'han pogut carregar les estadístiques d'ús de tokens: {{error}}", + 'Expected --format csv or --format json.': + "S'esperava --format csv o --format json.", + 'Expected a file path after --output.': + "S'esperava una ruta de fitxer després de --output.", + 'Unexpected argument: {{argument}}': 'Argument inesperat: {{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + 'Ús: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + "La ruta d'exportació de l'ús de tokens ha d'estar dins del directori de treball del projecte.", + 'Export target does not exist: {{path}}': + "La destinació d'exportació no existeix: {{path}}", + 'Cannot resolve export path within the working directory.': + "No s'ha pogut resoldre la ruta d'exportació dins del directori de treball.", + 'Could not create a temporary export file.': + "No s'ha pogut crear un fitxer temporal d'exportació.", + 'Token usage exported to {{format}}: {{path}}': + 'Ús de tokens exportat a {{format}}: {{path}}', + 'Failed to export token usage stats: {{error}}': + "No s'han pogut exportar les estadístiques d'ús de tokens: {{error}}", + 'Unclosed quote in arguments.': 'Cometes sense tancar als arguments.', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + 'Nota: el temps de generació (TTFT/TPS) pertany a les mètriques de generació.', 'exit the cli': 'sortir del CLI', 'Manage workspace directories': "Gestionar els directoris de l'espai de treball", diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 354f6e4cf71..b205f4bae7a 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -420,12 +420,58 @@ export default { 'Auto Edit': 'Automatisch bearbeiten', YOLO: 'YOLO', 'toggle vim mode on/off': 'Vim-Modus ein-/ausschalten', - 'check session stats. Usage: /stats [model|tools]': - 'Sitzungsstatistiken prüfen. Verwendung: /stats [model|tools]', 'Show model-specific usage statistics.': 'Modellspezifische Nutzungsstatistiken anzeigen.', 'Show tool-specific usage statistics.': 'Werkzeugspezifische Nutzungsstatistiken anzeigen.', + 'Show daily token usage statistics.': + 'Tägliche Token-Nutzungsstatistiken anzeigen.', + 'Show monthly token usage statistics.': + 'Monatliche Token-Nutzungsstatistiken anzeigen.', + 'Export token usage statistics to CSV or JSON.': + 'Token-Nutzungsstatistiken als CSV oder JSON exportieren.', + 'No usage data.': 'Keine Nutzungsdaten.', + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}}: {{tokens}} Tokens ({{requests}} Anfragen)', + 'Daily token usage for {{value}}': 'Tägliche Token-Nutzung für {{value}}', + 'Monthly token usage for {{value}}': 'Monatliche Token-Nutzung für {{value}}', + 'Total: {{tokens}} tokens': 'Gesamt: {{tokens}} Tokens', + 'Requests: {{requests}}': 'Anfragen: {{requests}}', + 'Breakdown:': 'Aufschlüsselung:', + 'Input: {{tokens}}': 'Eingabe: {{tokens}}', + 'Output: {{tokens}}': 'Ausgabe: {{tokens}}', + 'Cached (included in Input): {{tokens}}': + 'Cache (in Eingabe enthalten): {{tokens}}', + 'Thoughts: {{tokens}}': 'Gedanken: {{tokens}}', + 'By model:': 'Nach Modell:', + 'By auth type:': 'Nach Authentifizierungstyp:', + 'By model/auth type:': 'Nach Modell/Authentifizierungstyp:', + 'By source:': 'Nach Quelle:', + 'Failed to load token usage stats: {{error}}': + 'Token-Nutzungsstatistiken konnten nicht geladen werden: {{error}}', + 'Expected --format csv or --format json.': + '--format csv oder --format json erwartet.', + 'Expected a file path after --output.': + 'Nach --output wird ein Dateipfad erwartet.', + 'Unexpected argument: {{argument}}': 'Unerwartetes Argument: {{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + 'Verwendung: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + 'Der Exportpfad für die Token-Nutzung muss im Arbeitsverzeichnis des Projekts liegen.', + 'Export target does not exist: {{path}}': + 'Exportziel existiert nicht: {{path}}', + 'Cannot resolve export path within the working directory.': + 'Der Exportpfad kann nicht innerhalb des Arbeitsverzeichnisses aufgelöst werden.', + 'Could not create a temporary export file.': + 'Temporäre Exportdatei konnte nicht erstellt werden.', + 'Token usage exported to {{format}}: {{path}}': + 'Token-Nutzung nach {{format}} exportiert: {{path}}', + 'Failed to export token usage stats: {{error}}': + 'Token-Nutzungsstatistiken konnten nicht exportiert werden: {{error}}', + 'Unclosed quote in arguments.': + 'Nicht geschlossenes Anführungszeichen in Argumenten.', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + 'Hinweis: Generierungszeiten (TTFT/TPS) gehören zu den Generierungsmetriken.', 'exit the cli': 'CLI beenden', 'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten', 'Add directories to the workspace. Use comma to separate multiple paths': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 181aea45fbd..abea052dfae 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -521,6 +521,52 @@ export default { 'Show model-specific usage statistics.', 'Show tool-specific usage statistics.': 'Show tool-specific usage statistics.', + 'Show daily token usage statistics.': 'Show daily token usage statistics.', + 'Show monthly token usage statistics.': + 'Show monthly token usage statistics.', + 'Export token usage statistics to CSV or JSON.': + 'Export token usage statistics to CSV or JSON.', + 'No usage data.': 'No usage data.', + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}}: {{tokens}} tokens ({{requests}} requests)', + 'Daily token usage for {{value}}': 'Daily token usage for {{value}}', + 'Monthly token usage for {{value}}': 'Monthly token usage for {{value}}', + 'Total: {{tokens}} tokens': 'Total: {{tokens}} tokens', + 'Requests: {{requests}}': 'Requests: {{requests}}', + 'Breakdown:': 'Breakdown:', + 'Input: {{tokens}}': 'Input: {{tokens}}', + 'Output: {{tokens}}': 'Output: {{tokens}}', + 'Cached (included in Input): {{tokens}}': + 'Cached (included in Input): {{tokens}}', + 'Thoughts: {{tokens}}': 'Thoughts: {{tokens}}', + 'By model:': 'By model:', + 'By auth type:': 'By auth type:', + 'By model/auth type:': 'By model/auth type:', + 'By source:': 'By source:', + 'Failed to load token usage stats: {{error}}': + 'Failed to load token usage stats: {{error}}', + 'Expected --format csv or --format json.': + 'Expected --format csv or --format json.', + 'Expected a file path after --output.': + 'Expected a file path after --output.', + 'Unexpected argument: {{argument}}': 'Unexpected argument: {{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + 'Token usage export path must be within the project working directory.', + 'Export target does not exist: {{path}}': + 'Export target does not exist: {{path}}', + 'Cannot resolve export path within the working directory.': + 'Cannot resolve export path within the working directory.', + 'Could not create a temporary export file.': + 'Could not create a temporary export file.', + 'Token usage exported to {{format}}: {{path}}': + 'Token usage exported to {{format}}: {{path}}', + 'Failed to export token usage stats: {{error}}': + 'Failed to export token usage stats: {{error}}', + 'Unclosed quote in arguments.': 'Unclosed quote in arguments.', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.', 'exit the cli': 'exit the cli', 'Manage workspace directories': 'Manage workspace directories', 'Add directories to the workspace. Use comma to separate multiple paths': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 3e7ddd1fae4..737a247388c 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -494,12 +494,59 @@ export default { 'Auto Edit': 'Édition automatique', YOLO: 'YOLO', 'toggle vim mode on/off': 'activer/désactiver le mode Vim', - 'check session stats. Usage: /stats [model|tools]': - 'vérifier les stats de session. Utilisation : /stats [modèle|outils]', 'Show model-specific usage statistics.': "Afficher les statistiques d'utilisation spécifiques au modèle.", 'Show tool-specific usage statistics.': "Afficher les statistiques d'utilisation spécifiques aux outils.", + 'Show daily token usage statistics.': + "Afficher les statistiques quotidiennes d'utilisation des tokens.", + 'Show monthly token usage statistics.': + "Afficher les statistiques mensuelles d'utilisation des tokens.", + 'Export token usage statistics to CSV or JSON.': + "Exporter les statistiques d'utilisation des tokens en CSV ou JSON.", + 'No usage data.': "Aucune donnée d'utilisation.", + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}} : {{tokens}} tokens ({{requests}} requêtes)', + 'Daily token usage for {{value}}': + 'Utilisation quotidienne des tokens pour {{value}}', + 'Monthly token usage for {{value}}': + 'Utilisation mensuelle des tokens pour {{value}}', + 'Total: {{tokens}} tokens': 'Total : {{tokens}} tokens', + 'Requests: {{requests}}': 'Requêtes : {{requests}}', + 'Breakdown:': 'Détail :', + 'Input: {{tokens}}': 'Entrée : {{tokens}}', + 'Output: {{tokens}}': 'Sortie : {{tokens}}', + 'Cached (included in Input): {{tokens}}': + 'Cache (inclus dans l’entrée) : {{tokens}}', + 'Thoughts: {{tokens}}': 'Raisonnement : {{tokens}}', + 'By model:': 'Par modèle :', + 'By auth type:': "Par type d'authentification :", + 'By model/auth type:': "Par modèle/type d'authentification :", + 'By source:': 'Par source :', + 'Failed to load token usage stats: {{error}}': + "Échec du chargement des statistiques d'utilisation des tokens : {{error}}", + 'Expected --format csv or --format json.': + '--format csv ou --format json attendu.', + 'Expected a file path after --output.': + 'Un chemin de fichier est attendu après --output.', + 'Unexpected argument: {{argument}}': 'Argument inattendu : {{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + 'Utilisation : /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + "Le chemin d'export de l'utilisation des tokens doit rester dans le répertoire de travail du projet.", + 'Export target does not exist: {{path}}': + "La cible d'export n'existe pas : {{path}}", + 'Cannot resolve export path within the working directory.': + "Impossible de résoudre le chemin d'export dans le répertoire de travail.", + 'Could not create a temporary export file.': + "Impossible de créer un fichier d'export temporaire.", + 'Token usage exported to {{format}}: {{path}}': + 'Utilisation des tokens exportée en {{format}} : {{path}}', + 'Failed to export token usage stats: {{error}}': + "Échec de l'export des statistiques d'utilisation des tokens : {{error}}", + 'Unclosed quote in arguments.': 'Guillemet non fermé dans les arguments.', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + 'Remarque : les temps de génération (TTFT/TPS) relèvent des métriques de génération.', 'exit the cli': 'quitter le CLI', 'Manage workspace directories': "Gérer les répertoires de l'espace de travail", diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 8df65863f80..cd9e3bcbf4f 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -360,10 +360,53 @@ export default { 'Auto (detect from system)': '自動(システムから検出)', 'Auto (detect terminal theme)': '自動(端末テーマを検出)', Auto: '自動', - 'check session stats. Usage: /stats [model|tools]': - 'セッション統計を確認。使い方: /stats [model|tools]', 'Show model-specific usage statistics.': 'モデル別の使用統計を表示', 'Show tool-specific usage statistics.': 'ツール別の使用統計を表示', + 'Show daily token usage statistics.': '日次 token 使用統計を表示', + 'Show monthly token usage statistics.': '月次 token 使用統計を表示', + 'Export token usage statistics to CSV or JSON.': + 'token 使用統計を CSV または JSON にエクスポート', + 'No usage data.': '使用データはありません。', + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}}: {{tokens}} tokens({{requests}} リクエスト)', + 'Daily token usage for {{value}}': '{{value}} の日次 token 使用量', + 'Monthly token usage for {{value}}': '{{value}} の月次 token 使用量', + 'Total: {{tokens}} tokens': '合計: {{tokens}} tokens', + 'Requests: {{requests}}': 'リクエスト数: {{requests}}', + 'Breakdown:': '内訳:', + 'Input: {{tokens}}': '入力: {{tokens}}', + 'Output: {{tokens}}': '出力: {{tokens}}', + 'Cached (included in Input): {{tokens}}': + 'キャッシュ(入力に含まれる): {{tokens}}', + 'Thoughts: {{tokens}}': '思考: {{tokens}}', + 'By model:': 'モデル別:', + 'By auth type:': '認証タイプ別:', + 'By model/auth type:': 'モデル/認証タイプ別:', + 'By source:': 'ソース別:', + 'Failed to load token usage stats: {{error}}': + 'token 使用統計の読み込みに失敗しました: {{error}}', + 'Expected --format csv or --format json.': + '--format csv または --format json を指定してください。', + 'Expected a file path after --output.': + '--output の後にファイルパスを指定してください。', + 'Unexpected argument: {{argument}}': '予期しない引数: {{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + '使い方: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + 'token 使用量のエクスポート先はプロジェクト作業ディレクトリ内である必要があります。', + 'Export target does not exist: {{path}}': + 'エクスポート先が存在しません: {{path}}', + 'Cannot resolve export path within the working directory.': + '作業ディレクトリ内でエクスポートパスを解決できません。', + 'Could not create a temporary export file.': + '一時エクスポートファイルを作成できませんでした。', + 'Token usage exported to {{format}}: {{path}}': + 'token 使用量を {{format}} にエクスポートしました: {{path}}', + 'Failed to export token usage stats: {{error}}': + 'token 使用統計のエクスポートに失敗しました: {{error}}', + 'Unclosed quote in arguments.': '引数の引用符が閉じられていません。', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + '注: 生成時間(TTFT/TPS)は生成メトリクスに属します。', 'Manage workspace directories': 'ワークスペースディレクトリを管理', 'Add directories to the workspace. Use comma to separate multiple paths': 'ワークスペースにディレクトリを追加。複数パスはカンマで区切ってください', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index d9752548c9d..e74043465ed 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -443,12 +443,57 @@ export default { 'Auto Edit': 'Edição Automática', YOLO: 'YOLO', 'toggle vim mode on/off': 'alternar modo vim ligado/desligado', - 'check session stats. Usage: /stats [model|tools]': - 'verificar estatísticas da sessão. Uso: /stats [model|tools]', 'Show model-specific usage statistics.': 'Mostrar estatísticas de uso específicas do modelo.', 'Show tool-specific usage statistics.': 'Mostrar estatísticas de uso específicas da ferramenta.', + 'Show daily token usage statistics.': + 'Mostrar estatísticas diárias de uso de tokens.', + 'Show monthly token usage statistics.': + 'Mostrar estatísticas mensais de uso de tokens.', + 'Export token usage statistics to CSV or JSON.': + 'Exportar estatísticas de uso de tokens para CSV ou JSON.', + 'No usage data.': 'Nenhum dado de uso.', + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}}: {{tokens}} tokens ({{requests}} requisições)', + 'Daily token usage for {{value}}': 'Uso diário de tokens para {{value}}', + 'Monthly token usage for {{value}}': 'Uso mensal de tokens para {{value}}', + 'Total: {{tokens}} tokens': 'Total: {{tokens}} tokens', + 'Requests: {{requests}}': 'Requisições: {{requests}}', + 'Breakdown:': 'Detalhamento:', + 'Input: {{tokens}}': 'Entrada: {{tokens}}', + 'Output: {{tokens}}': 'Saída: {{tokens}}', + 'Cached (included in Input): {{tokens}}': + 'Cache (incluído na entrada): {{tokens}}', + 'Thoughts: {{tokens}}': 'Raciocínio: {{tokens}}', + 'By model:': 'Por modelo:', + 'By auth type:': 'Por tipo de autenticação:', + 'By model/auth type:': 'Por modelo/tipo de autenticação:', + 'By source:': 'Por origem:', + 'Failed to load token usage stats: {{error}}': + 'Falha ao carregar estatísticas de uso de tokens: {{error}}', + 'Expected --format csv or --format json.': + 'Esperado --format csv ou --format json.', + 'Expected a file path after --output.': + 'Esperado um caminho de arquivo após --output.', + 'Unexpected argument: {{argument}}': 'Argumento inesperado: {{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + 'Uso: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + 'O caminho de exportação do uso de tokens deve estar dentro do diretório de trabalho do projeto.', + 'Export target does not exist: {{path}}': + 'O destino da exportação não existe: {{path}}', + 'Cannot resolve export path within the working directory.': + 'Não foi possível resolver o caminho de exportação dentro do diretório de trabalho.', + 'Could not create a temporary export file.': + 'Não foi possível criar um arquivo temporário de exportação.', + 'Token usage exported to {{format}}: {{path}}': + 'Uso de tokens exportado para {{format}}: {{path}}', + 'Failed to export token usage stats: {{error}}': + 'Falha ao exportar estatísticas de uso de tokens: {{error}}', + 'Unclosed quote in arguments.': 'Aspas não fechadas nos argumentos.', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + 'Observação: o tempo de geração (TTFT/TPS) pertence às métricas de geração.', 'exit the cli': 'sair da cli', 'Manage workspace directories': 'Gerenciar diretórios do workspace', 'Add directories to the workspace. Use comma to separate multiple paths': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 07a3d29fc84..97d40499d3b 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -438,12 +438,58 @@ export default { 'Auto Edit': 'Авторедактирование', YOLO: 'YOLO', 'toggle vim mode on/off': 'Включение/выключение режима vim', - 'check session stats. Usage: /stats [model|tools]': - 'Просмотр статистики сессии. Использование: /stats [model|tools]', 'Show model-specific usage statistics.': 'Показать статистику использования модели.', 'Show tool-specific usage statistics.': 'Показать статистику использования инструментов.', + 'Show daily token usage statistics.': + 'Показать дневную статистику использования токенов.', + 'Show monthly token usage statistics.': + 'Показать месячную статистику использования токенов.', + 'Export token usage statistics to CSV or JSON.': + 'Экспортировать статистику использования токенов в CSV или JSON.', + 'No usage data.': 'Нет данных об использовании.', + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}}: {{tokens}} токенов ({{requests}} запросов)', + 'Daily token usage for {{value}}': + 'Дневное использование токенов за {{value}}', + 'Monthly token usage for {{value}}': + 'Месячное использование токенов за {{value}}', + 'Total: {{tokens}} tokens': 'Всего: {{tokens}} токенов', + 'Requests: {{requests}}': 'Запросы: {{requests}}', + 'Breakdown:': 'Разбивка:', + 'Input: {{tokens}}': 'Ввод: {{tokens}}', + 'Output: {{tokens}}': 'Вывод: {{tokens}}', + 'Cached (included in Input): {{tokens}}': 'Кэш (включён во ввод): {{tokens}}', + 'Thoughts: {{tokens}}': 'Рассуждения: {{tokens}}', + 'By model:': 'По модели:', + 'By auth type:': 'По типу аутентификации:', + 'By model/auth type:': 'По модели/типу аутентификации:', + 'By source:': 'По источнику:', + 'Failed to load token usage stats: {{error}}': + 'Не удалось загрузить статистику использования токенов: {{error}}', + 'Expected --format csv or --format json.': + 'Ожидается --format csv или --format json.', + 'Expected a file path after --output.': + 'После --output ожидается путь к файлу.', + 'Unexpected argument: {{argument}}': 'Неожиданный аргумент: {{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + 'Использование: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + 'Путь экспорта использования токенов должен находиться внутри рабочего каталога проекта.', + 'Export target does not exist: {{path}}': + 'Цель экспорта не существует: {{path}}', + 'Cannot resolve export path within the working directory.': + 'Не удалось определить путь экспорта внутри рабочего каталога.', + 'Could not create a temporary export file.': + 'Не удалось создать временный файл экспорта.', + 'Token usage exported to {{format}}: {{path}}': + 'Использование токенов экспортировано в {{format}}: {{path}}', + 'Failed to export token usage stats: {{error}}': + 'Не удалось экспортировать статистику использования токенов: {{error}}', + 'Unclosed quote in arguments.': 'Незакрытая кавычка в аргументах.', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + 'Примечание: время генерации (TTFT/TPS) относится к метрикам генерации.', 'exit the cli': 'Выход из CLI', 'Manage workspace directories': 'Управление директориями рабочего пространства', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 832d263b1cc..19acd466df3 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -454,8 +454,50 @@ export default { 'Auto Edit': '自動編輯', YOLO: 'YOLO', 'toggle vim mode on/off': '切換 vim 模式開關', - 'Show model-specific usage statistics.': '顯示模型相關的使用統計信息', - 'Show tool-specific usage statistics.': '顯示工具相關的使用統計信息', + 'Show model-specific usage statistics.': '顯示模型相關的使用統計資訊', + 'Show tool-specific usage statistics.': '顯示工具相關的使用統計資訊', + 'Show daily token usage statistics.': '顯示每日 token 使用統計資訊', + 'Show monthly token usage statistics.': '顯示每月 token 使用統計資訊', + 'Export token usage statistics to CSV or JSON.': + '將 token 使用統計資訊匯出為 CSV 或 JSON', + 'No usage data.': '沒有使用資料。', + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}}:{{tokens}} 個 token({{requests}} 個請求)', + 'Daily token usage for {{value}}': '{{value}} 的每日 token 使用情況', + 'Monthly token usage for {{value}}': '{{value}} 的每月 token 使用情況', + 'Total: {{tokens}} tokens': '總計:{{tokens}} 個 token', + 'Requests: {{requests}}': '請求數:{{requests}}', + 'Breakdown:': '明細:', + 'Input: {{tokens}}': '輸入:{{tokens}}', + 'Output: {{tokens}}': '輸出:{{tokens}}', + 'Cached (included in Input): {{tokens}}': + '快取(已包含在輸入中):{{tokens}}', + 'Thoughts: {{tokens}}': '思考:{{tokens}}', + 'By model:': '按模型:', + 'By auth type:': '按認證類型:', + 'By model/auth type:': '按模型/認證類型:', + 'By source:': '按來源:', + 'Failed to load token usage stats: {{error}}': + '載入 token 使用統計資訊失敗:{{error}}', + 'Expected --format csv or --format json.': + '應為 --format csv 或 --format json。', + 'Expected a file path after --output.': '--output 後應提供檔案路徑。', + 'Unexpected argument: {{argument}}': '未預期的參數:{{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + '用法:/stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + 'Token 使用匯出路徑必須位於專案工作目錄內。', + 'Export target does not exist: {{path}}': '匯出目標不存在:{{path}}', + 'Cannot resolve export path within the working directory.': + '無法在工作目錄內解析匯出路徑。', + 'Could not create a temporary export file.': '無法建立臨時匯出檔案。', + 'Token usage exported to {{format}}: {{path}}': + 'Token 使用情況已匯出為 {{format}}:{{path}}', + 'Failed to export token usage stats: {{error}}': + '匯出 token 使用統計資訊失敗:{{error}}', + 'Unclosed quote in arguments.': '參數中有未閉合的引號。', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + '注意:生成耗時(TTFT/TPS)屬於生成指標。', 'exit the cli': '退出命令行界面', 'Manage workspace directories': '管理工作區目錄', 'Add directories to the workspace. Use comma to separate multiple paths': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 3d96c0970e4..1e468be4aa1 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -498,6 +498,48 @@ export default { 'Show usage statistics dashboard.': '显示使用统计面板。', 'Show model-specific usage statistics.': '显示模型相关的使用统计信息', 'Show tool-specific usage statistics.': '显示工具相关的使用统计信息', + 'Show daily token usage statistics.': '显示每日 token 使用统计信息', + 'Show monthly token usage statistics.': '显示每月 token 使用统计信息', + 'Export token usage statistics to CSV or JSON.': + '将 token 使用统计信息导出为 CSV 或 JSON', + 'No usage data.': '没有使用数据。', + '{{label}}: {{tokens}} tokens ({{requests}} requests)': + '{{label}}:{{tokens}} 个 token({{requests}} 个请求)', + 'Daily token usage for {{value}}': '{{value}} 的每日 token 使用情况', + 'Monthly token usage for {{value}}': '{{value}} 的每月 token 使用情况', + 'Total: {{tokens}} tokens': '总计:{{tokens}} 个 token', + 'Requests: {{requests}}': '请求数:{{requests}}', + 'Breakdown:': '明细:', + 'Input: {{tokens}}': '输入:{{tokens}}', + 'Output: {{tokens}}': '输出:{{tokens}}', + 'Cached (included in Input): {{tokens}}': + '缓存(已包含在输入中):{{tokens}}', + 'Thoughts: {{tokens}}': '思考:{{tokens}}', + 'By model:': '按模型:', + 'By auth type:': '按认证类型:', + 'By model/auth type:': '按模型/认证类型:', + 'By source:': '按来源:', + 'Failed to load token usage stats: {{error}}': + '加载 token 使用统计信息失败:{{error}}', + 'Expected --format csv or --format json.': + '应为 --format csv 或 --format json。', + 'Expected a file path after --output.': '--output 后应提供文件路径。', + 'Unexpected argument: {{argument}}': '意外参数:{{argument}}', + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]': + '用法:/stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + 'Token usage export path must be within the project working directory.': + 'Token 使用导出路径必须位于项目工作目录内。', + 'Export target does not exist: {{path}}': '导出目标不存在:{{path}}', + 'Cannot resolve export path within the working directory.': + '无法在工作目录内解析导出路径。', + 'Could not create a temporary export file.': '无法创建临时导出文件。', + 'Token usage exported to {{format}}: {{path}}': + 'Token 使用情况已导出为 {{format}}:{{path}}', + 'Failed to export token usage stats: {{error}}': + '导出 token 使用统计信息失败:{{error}}', + 'Unclosed quote in arguments.': '参数中存在未闭合的引号。', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.': + '注意:生成耗时(TTFT/TPS)归属于生成指标。', 'exit the cli': '退出命令行界面', 'Manage workspace directories': '管理工作区目录', 'Add directories to the workspace. Use comma to separate multiple paths': diff --git a/packages/cli/src/i18n/mustTranslateKeys.ts b/packages/cli/src/i18n/mustTranslateKeys.ts index 9db6fc6c9a9..cfb6b7538cf 100644 --- a/packages/cli/src/i18n/mustTranslateKeys.ts +++ b/packages/cli/src/i18n/mustTranslateKeys.ts @@ -95,6 +95,16 @@ export const MUST_TRANSLATE_KEYS = [ 'Invalid approval mode "{{arg}}". Valid modes: {{modes}}', 'Approval mode set to "{{mode}}"', "Set up Qwen Code's status line UI", + 'Cached (included in Input): {{tokens}}', + 'By source:', + 'Unclosed quote in arguments.', + 'Token usage export path must be within the project working directory.', + 'Failed to load token usage stats: {{error}}', + 'Failed to export token usage stats: {{error}}', + 'Note: generation timing (TTFT/TPS) belongs to generation metrics.', + 'Cannot resolve export path within the working directory.', + 'Export target does not exist: {{path}}', + 'Could not create a temporary export file.', 'Activity', 'Efficiency', 'Today', diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 0e5295b58f2..dc0ae00b12a 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -4,13 +4,51 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + access, + link, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { statsCommand } from './statsCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { MessageType } from '../types.js'; -import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; -import type { ModelMetricsCore, ModelMetrics } from '@qwen-code/qwen-code-core'; +import { setLanguageAsync } from '../../i18n/index.js'; +import { + ApiResponseEvent, + AuthType, + type Config, + MAIN_SOURCE, + type ModelMetrics, + type ModelMetricsCore, + Storage, + getTokenUsageFilePath, + recordTokenUsageFromApiResponse, +} from '@qwen-code/qwen-code-core'; + +const fsPromisesMock = vi.hoisted(() => ({ + open: vi.fn(), + rename: vi.fn(), +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + fsPromisesMock.open.mockImplementation(actual.open); + fsPromisesMock.rename.mockImplementation(actual.rename); + return { + ...actual, + open: fsPromisesMock.open, + rename: fsPromisesMock.rename, + }; +}); const toModelMetrics = (core: ModelMetricsCore): ModelMetrics => ({ ...core, @@ -21,10 +59,23 @@ describe('statsCommand', () => { let mockContext: CommandContext; const startTime = new Date('2025-07-14T10:00:00.000Z'); const endTime = new Date('2025-07-14T10:00:30.000Z'); + let tempDir: string; + let originalRuntimeDir: string | undefined; - beforeEach(() => { + beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(endTime); + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + fsPromisesMock.open.mockReset(); + fsPromisesMock.open.mockImplementation(actualFs.open); + fsPromisesMock.rename.mockReset(); + fsPromisesMock.rename.mockImplementation(actualFs.rename); + originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + tempDir = await mkdtemp(path.join(tmpdir(), 'qwen-stats-command-')); + process.env['QWEN_RUNTIME_DIR'] = tempDir; // 1. Create the mock context with all default values mockContext = createMockCommandContext(); @@ -33,6 +84,65 @@ describe('statsCommand', () => { mockContext.session.stats.sessionStartTime = startTime; }); + afterEach(async () => { + vi.useRealTimers(); + await setLanguageAsync('en'); + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } + Storage.setRuntimeBaseDir(null); + await rm(tempDir, { recursive: true, force: true }); + }); + + function createUsageConfig(): Config { + return { + getSessionId: () => 'session-1', + getProjectRoot: () => tempDir, + getWorkingDir: () => tempDir, + } as unknown as Config; + } + + async function seedUsage() { + const config = createUsageConfig(); + const first = new ApiResponseEvent( + 'response-1', + 'model-a', + 100, + 'prompt-1', + AuthType.USE_GEMINI, + { + promptTokenCount: 10, + candidatesTokenCount: 20, + cachedContentTokenCount: 5, + thoughtsTokenCount: 2, + totalTokenCount: 32, + }, + ); + first['event.timestamp'] = '2025-07-14T10:00:00.000Z'; + const second = new ApiResponseEvent( + 'response-2', + 'model-b', + 200, + 'prompt-2', + AuthType.USE_VERTEX_AI, + { + promptTokenCount: 7, + candidatesTokenCount: 8, + cachedContentTokenCount: 1, + thoughtsTokenCount: 0, + totalTokenCount: 15, + }, + ); + second['event.timestamp'] = '2025-07-14T11:00:00.000Z'; + + await recordTokenUsageFromApiResponse(config, first); + await recordTokenUsageFromApiResponse(config, second); + + mockContext.services.config = config; + } + it('should open stats dialog when run with no subcommand in interactive mode', () => { if (!statsCommand.action) throw new Error('Command has no action'); @@ -186,7 +296,6 @@ describe('statsCommand', () => { cached: 0, total: 1_500_000, thoughts: 0, - tool: 0, }, api: { totalRequests: 10, @@ -225,7 +334,6 @@ describe('statsCommand', () => { cached: 0, total: 1_500_000, thoughts: 0, - tool: 0, }, api: { totalRequests: 10, @@ -278,7 +386,6 @@ describe('statsCommand', () => { cached: 0, total: 3_000_000, thoughts: 0, - tool: 0, }, api: { totalRequests: 20, @@ -293,7 +400,6 @@ describe('statsCommand', () => { cached: 0, total: 700_000, thoughts: 0, - tool: 0, }, api: { totalRequests: 5, @@ -346,7 +452,6 @@ describe('statsCommand', () => { cached: 0, total: 2_000_000, thoughts: 0, - tool: 0, }, api: { totalRequests: 10, @@ -361,7 +466,6 @@ describe('statsCommand', () => { cached: 0, total: 2_000_000, thoughts: 0, - tool: 0, }, api: { totalRequests: 10, @@ -414,7 +518,6 @@ describe('statsCommand', () => { cached: 0, total: 0, thoughts: 0, - tool: 0, }, api: { totalRequests: 0, @@ -462,7 +565,6 @@ describe('statsCommand', () => { cached: 0, total: 2_000_000, thoughts: 0, - tool: 0, }, api: { totalRequests: 10, @@ -482,4 +584,683 @@ describe('statsCommand', () => { expect(result.content).toContain('Estimated cost: $0.3000'); }); }); + + describe('historical token usage subcommands', () => { + it('stats daily returns persisted token usage in non-interactive mode', async () => { + await seedUsage(); + const dailySubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'daily', + ); + if (!dailySubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await dailySubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + '2025-07-14', + )) as { type: string; messageType: string; content: string }; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('Daily token usage for 2025-07-14'); + expect(result.content).toContain('Total: 47 tokens'); + expect(result.content).toContain('Cached (included in Input): 6'); + expect(result.content).toContain('model-a: 32 tokens'); + expect(result.content).toContain('model-b: 15 tokens'); + expect(result.content).toContain('vertex-ai: 15 tokens'); + expect(result.content).toContain('main: 47 tokens'); + expect(result.content).not.toContain('API response duration only'); + expect(result.content).toContain('TTFT/TPS'); + }); + + it('stats monthly writes persisted token usage to the interactive history', async () => { + await seedUsage(); + const monthlySubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'monthly', + ); + if (!monthlySubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + await monthlySubCommand.action(mockContext, '2025-07'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: expect.stringContaining('Monthly token usage for 2025-07'), + }, + expect.any(Number), + ); + }); + + it('stats daily surfaces usage file read failures', async () => { + await seedUsage(); + const dailySubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'daily', + ); + if (!dailySubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const usageFile = getTokenUsageFilePath('2025-07'); + await rm(usageFile, { force: true }); + await mkdir(usageFile); + + const result = (await dailySubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + '2025-07-14', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain('Failed to load token usage stats'); + expect(result.content).not.toContain('Total: 0 tokens'); + }); + + it('stats export writes CSV with a default filename', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --format csv', + )) as { type: string; messageType: string; content: string }; + + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect(result.content).toContain( + 'Token usage exported to CSV: qwen-token-usage-month-2025-07.csv', + ); + const csv = await readFile( + path.join(tempDir, 'qwen-token-usage-month-2025-07.csv'), + 'utf-8', + ); + expect(csv).toContain( + 'period,value,group_type,group_key,model,auth_type,source,requests,input_tokens,output_tokens,cached_tokens,thoughts_tokens,total_tokens,api_duration_ms', + ); + expect(csv).toContain('month,2025-07,total,total,,,,2,17,28,6,2,47,300'); + }); + + it('stats export adds interactive info and error messages', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const infoResult = await exportSubCommand.action( + mockContext, + 'monthly 2025-07 --format csv', + ); + + expect(infoResult).toBeUndefined(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: expect.stringContaining('Token usage exported to CSV'), + }, + expect.any(Number), + ); + + const errorContext = createMockCommandContext({ + services: { config: mockContext.services.config }, + }); + const errorResult = await exportSubCommand.action( + errorContext, + 'monthly 2025-07 --output ../outside.csv', + ); + + expect(errorResult).toBeUndefined(); + expect(errorContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: expect.stringContaining('within the project working directory'), + }, + expect.any(Number), + ); + }); + + it('stats export writes JSON to an explicit output path', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'daily 2025-07-14 --format json --output usage/day.json', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('info'); + expect(result.content).toContain('usage'); + const json = JSON.parse( + await readFile(path.join(tempDir, 'usage', 'day.json'), 'utf-8'), + ) as { + totals: { totalTokens: number }; + byModel: Array<{ key: string }>; + coordination?: unknown; + }; + expect(json.totals.totalTokens).toBe(47); + expect(json.coordination).toBeUndefined(); + expect(json.byModel.map((group) => group.key)).toEqual([ + 'model-a', + 'model-b', + ]); + }); + + it('stats export accepts equals syntax for format and output', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'daily 2025-07-14 --format=json --output=usage/day-equals.json', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('info'); + const json = JSON.parse( + await readFile(path.join(tempDir, 'usage', 'day-equals.json'), 'utf-8'), + ) as { totals: { totalTokens: number } }; + expect(json.totals.totalTokens).toBe(47); + }); + + it('stats export accepts short format and output flags', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 -f csv -o usage/month-short.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('info'); + await expect( + readFile(path.join(tempDir, 'usage', 'month-short.csv'), 'utf-8'), + ).resolves.toContain('month,2025-07,total,total'); + }); + + it('stats export preserves Windows-style backslashes in output paths', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output usage\\month.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('info'); + const expectedPath = + process.platform === 'win32' + ? path.join(tempDir, 'usage', 'month.csv') + : path.join(tempDir, 'usage\\month.csv'); + const csv = await readFile(expectedPath, 'utf-8'); + expect(csv).toContain('month,2025-07,total,total'); + }); + + it('stats export preserves quoted Windows-style backslashes in output paths', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output "usage\\\\quoted month.csv"', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('info'); + const expectedPath = + process.platform === 'win32' + ? path.join(tempDir, 'usage', 'quoted month.csv') + : path.join(tempDir, 'usage\\\\quoted month.csv'); + const csv = await readFile(expectedPath, 'utf-8'); + expect(csv).toContain('month,2025-07,total,total'); + }); + + it('stats export rejects paths outside the working directory', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output ../outside.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain('within the project working directory'); + }); + + it('stats export rejects symlinked directories that resolve outside the working directory', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const outsideDir = await mkdtemp( + path.join(tmpdir(), 'qwen-stats-outside-'), + ); + try { + const linkPath = path.join(tempDir, 'linked-outside'); + await symlink( + outsideDir, + linkPath, + process.platform === 'win32' ? 'junction' : 'dir', + ); + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + `monthly 2025-07 --output ${path.join('linked-outside', 'usage.csv')}`, + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain( + 'within the project working directory', + ); + await expect( + access(path.join(outsideDir, 'usage.csv')), + ).rejects.toThrow(); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('stats export rejects symlinked output files before writing', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const outsideDir = await mkdtemp( + path.join(tmpdir(), 'qwen-stats-outside-'), + ); + try { + const outsideFile = path.join(outsideDir, 'usage.csv'); + await writeFile(outsideFile, 'outside-original', 'utf-8'); + + try { + await symlink(outsideFile, path.join(tempDir, 'linked-file.csv')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EPERM') { + return; + } + throw error; + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output linked-file.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain( + 'within the project working directory', + ); + await expect(readFile(outsideFile, 'utf-8')).resolves.toBe( + 'outside-original', + ); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('stats export writes through the real output directory after validation', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output nested/real.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('info'); + await expect( + readFile(path.join(tempDir, 'nested', 'real.csv'), 'utf-8'), + ).resolves.toContain('month,2025-07,total,total'); + }); + + it('stats export replaces existing hardlinks without mutating the linked outside file', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const outsideDir = await mkdtemp( + path.join(tmpdir(), 'qwen-stats-outside-'), + ); + try { + const outsideFile = path.join(outsideDir, 'usage.csv'); + const hardlinkPath = path.join(tempDir, 'hardlink.csv'); + await writeFile(outsideFile, 'outside-original', 'utf-8'); + await link(outsideFile, hardlinkPath); + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output hardlink.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('info'); + await expect(readFile(outsideFile, 'utf-8')).resolves.toBe( + 'outside-original', + ); + await expect(readFile(hardlinkPath, 'utf-8')).resolves.toContain( + 'month,2025-07,total,total', + ); + } finally { + await rm(outsideDir, { recursive: true, force: true }); + } + }); + + it('stats export reports a missing final target after rename', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const { rename: realRename } = + await vi.importActual( + 'node:fs/promises', + ); + fsPromisesMock.rename.mockImplementationOnce(async (oldPath, newPath) => { + await realRename(oldPath, newPath); + await rm(newPath, { force: true }); + }); + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output missing-after-rename.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain('Export target does not exist:'); + expect(result.content).toContain('missing-after-rename.csv'); + }); + + it('stats export retries temporary file name collisions', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + fsPromisesMock.open.mockClear(); + fsPromisesMock.open.mockRejectedValueOnce( + Object.assign(new Error('exists'), { code: 'EEXIST' }), + ); + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output retry.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('info'); + expect(fsPromisesMock.open).toHaveBeenCalledTimes(2); + await expect( + readFile(path.join(tempDir, 'retry.csv'), 'utf-8'), + ).resolves.toContain('month,2025-07,total,total'); + }); + + it('stats export reports temporary file collision exhaustion', async () => { + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + fsPromisesMock.open.mockClear(); + fsPromisesMock.open.mockRejectedValue( + Object.assign(new Error('exists'), { code: 'EEXIST' }), + ); + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output exhausted.csv', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain( + 'Could not create a temporary export file.', + ); + expect(fsPromisesMock.open).toHaveBeenCalledTimes(10); + }); + + it('stats export rejects Windows alternate data stream output paths', async () => { + if (process.platform !== 'win32') { + return; + } + + await seedUsage(); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output usage.csv:stream', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain('within the project working directory'); + await expect(access(path.join(tempDir, 'usage.csv'))).rejects.toThrow(); + }); + + it('stats export reports invalid arguments', async () => { + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --format xml', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain( + 'Expected --format csv or --format json', + ); + }); + + it('stats export preserves doubled backslashes inside quoted arguments', async () => { + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 out.csv "usage\\\\extra.csv"', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain( + 'Unexpected argument: usage\\\\extra.csv', + ); + }); + + it('stats export reports unclosed quotes', async () => { + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + const result = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --format "json', + )) as { type: string; messageType: string; content: string }; + + expect(result.messageType).toBe('error'); + expect(result.content).toContain('Unclosed quote in arguments'); + }); + + it('uses locale translations for historical token usage output', async () => { + await seedUsage(); + await setLanguageAsync('zh'); + const dailySubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'daily', + ); + const exportSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'export', + ); + if (!dailySubCommand?.action || !exportSubCommand?.action) { + throw new Error('Subcommand has no action'); + } + + expect(dailySubCommand.description).toBe('显示每日 token 使用统计信息'); + expect(exportSubCommand.description).toBe( + '将 token 使用统计信息导出为 CSV 或 JSON', + ); + + const dailyResult = (await dailySubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + '2025-07-14', + )) as { type: string; messageType: string; content: string }; + + expect(dailyResult.content).toContain('2025-07-14 的每日 token 使用情况'); + expect(dailyResult.content).toContain('总计:47 个 token'); + expect(dailyResult.content).toContain('model-a:32 个 token(1 个请求)'); + expect(dailyResult.content).toContain('缓存(已包含在输入中):6'); + expect(dailyResult.content).toContain('按来源:'); + expect(dailyResult.content).toContain( + '生成耗时(TTFT/TPS)归属于生成指标', + ); + + const exportResult = (await exportSubCommand.action( + createMockCommandContext({ + executionMode: 'non_interactive', + services: { config: mockContext.services.config }, + }), + 'monthly 2025-07 --output ../outside.csv', + )) as { type: string; messageType: string; content: string }; + + expect(exportResult.messageType).toBe('error'); + expect(exportResult.content).toContain( + 'Token 使用导出路径必须位于项目工作目录内', + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index a7be574660d..f1cb4325bb0 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as fs from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; import { MessageType } from '../types.js'; import { formatDuration } from '../utils/formatters.js'; import { @@ -13,8 +16,619 @@ import { type OpenDialogActionReturn, CommandKind, } from './types.js'; -import { t } from '../../i18n/index.js'; +import { getCurrentLanguage, t } from '../../i18n/index.js'; import { calculateCost } from '../../utils/costCalculator.js'; +import { + formatTokenUsageSummaryAsCsv, + formatTokenUsageSummaryAsJson, + isSubpath, + queryTokenUsage, + type TokenUsageExportFormat, + type TokenUsageGroupSummary, + type TokenUsagePeriod, + type TokenUsageSummary, +} from '@qwen-code/qwen-code-core'; + +const VALID_EXPORT_FORMATS = new Set(['csv', 'json']); + +type ParsedStatsExportArgs = { + period: TokenUsagePeriod; + value?: string; + format: TokenUsageExportFormat; + outputPath?: string; +}; + +function formatInteger(value: number): string { + return new Intl.NumberFormat(getCurrentLanguage()).format(value); +} + +function formatGroupLines( + titleKey: string, + groups: TokenUsageGroupSummary[], +): string[] { + if (groups.length === 0) { + return [t(titleKey), ` ${t('No usage data.')}`]; + } + return [ + t(titleKey), + ...groups.map((group) => { + const label = + group.model && group.authType + ? `${group.model} (${group.authType})` + : group.model || group.authType || group.source || group.key; + return ` ${t('{{label}}: {{tokens}} tokens ({{requests}} requests)', { + label, + tokens: formatInteger(group.totalTokens), + requests: formatInteger(group.requests), + })}`; + }), + ]; +} + +function formatTokenUsageSummary(summary: TokenUsageSummary): string { + const label = + summary.period === 'day' + ? t('Daily token usage for {{value}}', { value: summary.value }) + : t('Monthly token usage for {{value}}', { value: summary.value }); + + return [ + label, + t('Total: {{tokens}} tokens', { + tokens: formatInteger(summary.totals.totalTokens), + }), + t('Requests: {{requests}}', { + requests: formatInteger(summary.totals.requests), + }), + '', + t('Breakdown:'), + ` ${t('Input: {{tokens}}', { + tokens: formatInteger(summary.totals.inputTokens), + })}`, + ` ${t('Output: {{tokens}}', { + tokens: formatInteger(summary.totals.outputTokens), + })}`, + ` ${t('Cached (included in Input): {{tokens}}', { + tokens: formatInteger(summary.totals.cachedTokens), + })}`, + ` ${t('Thoughts: {{tokens}}', { + tokens: formatInteger(summary.totals.thoughtsTokens), + })}`, + '', + ...formatGroupLines('By model:', summary.byModel), + '', + ...formatGroupLines('By auth type:', summary.byAuthType), + '', + ...formatGroupLines('By model/auth type:', summary.byModelAndAuthType), + '', + ...formatGroupLines('By source:', summary.bySource), + '', + t('Note: generation timing (TTFT/TPS) belongs to generation metrics.'), + ].join('\n'); +} + +function asMessage( + content: string, + messageType: MessageActionReturn['messageType'] = 'info', +): MessageActionReturn { + return { + type: 'message', + messageType, + content, + }; +} + +function addInteractiveMessage( + context: CommandContext, + content: string, + type: MessageType.INFO | MessageType.ERROR = MessageType.INFO, +): void { + context.ui.addItem( + { + type, + text: content, + }, + Date.now(), + ); +} + +async function showTokenUsageStats( + context: CommandContext, + period: TokenUsagePeriod, + args: string, +): Promise { + try { + const value = args.trim() || undefined; + const summary = await queryTokenUsage({ period, value }); + const content = formatTokenUsageSummary(summary); + if (context.executionMode !== 'interactive') { + return asMessage(content); + } + addInteractiveMessage(context, content); + } catch (error) { + const content = t('Failed to load token usage stats: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }); + if (context.executionMode !== 'interactive') { + return asMessage(content, 'error'); + } + addInteractiveMessage(context, content, MessageType.ERROR); + } +} + +function tokenizeArgs(args: string): string[] { + const tokens: string[] = []; + let current = ''; + let quote: '"' | "'" | undefined; + + for (let index = 0; index < args.length; index++) { + const char = args[index]!; + if (quote) { + if (char === '\\') { + if (args[index + 1] === quote) { + current += quote; + index++; + } else { + current += char; + } + continue; + } + if (char === quote) { + quote = undefined; + } else { + current += char; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (/\s/.test(char)) { + if (current.length > 0) { + tokens.push(current); + current = ''; + } + continue; + } + current += char; + } + + if (quote !== undefined) { + throw new Error(t('Unclosed quote in arguments.')); + } + if (current.length > 0) { + tokens.push(current); + } + return tokens; +} + +function isNotFoundError(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +function statsExportPathError(): MessageActionReturn { + return asMessage( + t('Token usage export path must be within the project working directory.'), + 'error', + ); +} + +function statsExportTargetMissingError( + targetPath: string, +): MessageActionReturn { + return asMessage( + t('Export target does not exist: {{path}}', { path: targetPath }), + 'error', + ); +} + +async function realpathNearestExisting( + targetDirectory: string, + cwd: string, +): Promise { + let currentDirectory = targetDirectory; + + while (isSubpath(cwd, currentDirectory)) { + try { + return await fs.realpath(currentDirectory); + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + + const parentDirectory = path.dirname(currentDirectory); + if (parentDirectory === currentDirectory) { + break; + } + currentDirectory = parentDirectory; + } + } + + throw new Error( + t('Cannot resolve export path within the working directory.'), + ); +} + +async function validateStatsExportExistingParent( + cwd: string, + outputDirectory: string, +): Promise { + const [realCwd, realExistingParent] = await Promise.all([ + fs.realpath(cwd), + realpathNearestExisting(outputDirectory, cwd), + ]); + + if (!isSubpath(realCwd, realExistingParent)) { + return statsExportPathError(); + } + + return undefined; +} + +async function validateStatsExportDirectory( + cwd: string, + outputDirectory: string, +): Promise { + const [realCwd, realOutputDirectory] = await Promise.all([ + fs.realpath(cwd), + fs.realpath(outputDirectory), + ]); + + if (!isSubpath(realCwd, realOutputDirectory)) { + return statsExportPathError(); + } + + return undefined; +} + +function validateStatsExportPathShape( + targetPath: string, +): MessageActionReturn | undefined { + // Avoid Windows alternate data streams such as "report.csv:secret". + if (process.platform === 'win32' && path.basename(targetPath).includes(':')) { + return statsExportPathError(); + } + + return undefined; +} + +async function validateStatsExportFileTarget( + targetPath: string, +): Promise { + try { + const stats = await fs.lstat(targetPath); + if (stats.isSymbolicLink() || !stats.isFile()) { + return statsExportPathError(); + } + } catch (error) { + if (isNotFoundError(error)) { + return undefined; + } + throw error; + } + + return undefined; +} + +async function validateStatsExportFinalFile( + cwd: string, + targetPath: string, +): Promise { + let stats; + try { + stats = await fs.lstat(targetPath); + } catch (error) { + if (isNotFoundError(error)) { + return statsExportTargetMissingError(targetPath); + } + throw error; + } + + if (stats.isSymbolicLink() || !stats.isFile()) { + return statsExportPathError(); + } + + const [realCwd, realTargetPath] = await Promise.all([ + fs.realpath(cwd), + fs.realpath(targetPath), + ]); + + if (!isSubpath(realCwd, realTargetPath)) { + return statsExportPathError(); + } + + return undefined; +} + +function createTemporaryExportPath( + outputDirectory: string, + targetPath: string, +): string { + return path.join( + outputDirectory, + `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`, + ); +} + +function isSamePath(leftPath: string, rightPath: string): boolean { + return isSubpath(leftPath, rightPath) && isSubpath(rightPath, leftPath); +} + +async function validateStatsExportDirectoryIdentity( + cwd: string, + outputDirectory: string, + expectedRealOutputDirectory: string, +): Promise { + const [realCwd, realOutputDirectory] = await Promise.all([ + fs.realpath(cwd), + fs.realpath(outputDirectory), + ]); + + if ( + !isSubpath(realCwd, realOutputDirectory) || + !isSamePath(expectedRealOutputDirectory, realOutputDirectory) + ) { + return statsExportPathError(); + } + + return undefined; +} + +async function writeStatsExportFileAtomically( + cwd: string, + outputDirectory: string, + targetPath: string, + content: string, +): Promise { + const initialDirectoryError = await validateStatsExportDirectory( + cwd, + outputDirectory, + ); + if (initialDirectoryError) { + return initialDirectoryError; + } + + const [realCwd, realOutputDirectory] = await Promise.all([ + fs.realpath(cwd), + fs.realpath(outputDirectory), + ]); + const realTargetPath = path.join( + realOutputDirectory, + path.basename(targetPath), + ); + + if (!isSubpath(realCwd, realTargetPath)) { + return statsExportPathError(); + } + + for (let attempt = 0; attempt < 10; attempt++) { + let tempPath: string | undefined = createTemporaryExportPath( + realOutputDirectory, + targetPath, + ); + + try { + if (!isSubpath(realCwd, tempPath)) { + return statsExportPathError(); + } + + const preTempDirectoryError = await validateStatsExportDirectoryIdentity( + cwd, + outputDirectory, + realOutputDirectory, + ); + if (preTempDirectoryError) { + return preTempDirectoryError; + } + + const preTempFileError = + await validateStatsExportFileTarget(realTargetPath); + if (preTempFileError) { + return preTempFileError; + } + + const file = await fs.open(tempPath, 'wx', 0o600); + try { + await file.writeFile(content, { encoding: 'utf-8' }); + } finally { + await file.close(); + } + + const tempFileError = await validateStatsExportFinalFile(cwd, tempPath); + if (tempFileError) { + return tempFileError; + } + + const preRenameDirectoryError = + await validateStatsExportDirectoryIdentity( + cwd, + outputDirectory, + realOutputDirectory, + ); + if (preRenameDirectoryError) { + return preRenameDirectoryError; + } + + const preRenameFileError = + await validateStatsExportFileTarget(realTargetPath); + if (preRenameFileError) { + return preRenameFileError; + } + + await fs.rename(tempPath, realTargetPath); + tempPath = undefined; + + return validateStatsExportFinalFile(cwd, realTargetPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + continue; + } + throw error; + } finally { + if (tempPath) { + await fs.rm(tempPath, { force: true }).catch(() => undefined); + } + } + } + + throw new Error(t('Could not create a temporary export file.')); +} + +function parseStatsExportArgs(args: string): ParsedStatsExportArgs { + const tokens = tokenizeArgs(args); + let period: TokenUsagePeriod | undefined; + let value: string | undefined; + let format: TokenUsageExportFormat = 'csv'; + let outputPath: string | undefined; + + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index]!; + if (token === '--format' || token === '-f') { + const next = tokens[++index]; + if (!next || !VALID_EXPORT_FORMATS.has(next as TokenUsageExportFormat)) { + throw new Error(t('Expected --format csv or --format json.')); + } + format = next as TokenUsageExportFormat; + continue; + } + if (token.startsWith('--format=')) { + const next = token.slice('--format='.length); + if (!VALID_EXPORT_FORMATS.has(next as TokenUsageExportFormat)) { + throw new Error(t('Expected --format csv or --format json.')); + } + format = next as TokenUsageExportFormat; + continue; + } + if (token === '--output' || token === '-o') { + const next = tokens[++index]; + if (!next) { + throw new Error(t('Expected a file path after --output.')); + } + outputPath = next; + continue; + } + if (token.startsWith('--output=')) { + outputPath = token.slice('--output='.length); + continue; + } + if (!period && (token === 'daily' || token === 'day')) { + period = 'day'; + continue; + } + if (!period && (token === 'monthly' || token === 'month')) { + period = 'month'; + continue; + } + if (!value) { + value = token; + continue; + } + if (!outputPath) { + outputPath = token; + continue; + } + throw new Error( + t('Unexpected argument: {{argument}}', { argument: token }), + ); + } + + if (!period) { + throw new Error( + t( + 'Usage: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', + ), + ); + } + + return { + period, + value, + format, + outputPath, + }; +} + +function getConfigCwd(context: CommandContext): string { + const config = context.services.config; + return config?.getWorkingDir() || config?.getProjectRoot() || process.cwd(); +} + +async function writeStatsExport( + context: CommandContext, + args: string, +): Promise { + try { + const parsed = parseStatsExportArgs(args); + const summary = await queryTokenUsage({ + period: parsed.period, + value: parsed.value, + }); + const content = + parsed.format === 'json' + ? formatTokenUsageSummaryAsJson(summary) + : `${formatTokenUsageSummaryAsCsv(summary)}\n`; + const cwd = path.resolve(getConfigCwd(context)); + const defaultFilename = `qwen-token-usage-${summary.period}-${summary.value}.${parsed.format}`; + const targetPath = path.resolve(cwd, parsed.outputPath || defaultFilename); + const outputDirectory = path.dirname(targetPath); + + if (!isSubpath(cwd, targetPath) || !isSubpath(cwd, outputDirectory)) { + return statsExportPathError(); + } + + const pathShapeError = validateStatsExportPathShape(targetPath); + if (pathShapeError) { + return pathShapeError; + } + + const parentError = await validateStatsExportExistingParent( + cwd, + outputDirectory, + ); + if (parentError) { + return parentError; + } + + await fs.mkdir(outputDirectory, { recursive: true }); + + const postMkdirError = await validateStatsExportDirectory( + cwd, + outputDirectory, + ); + if (postMkdirError) { + return postMkdirError; + } + + const preWriteFileError = await validateStatsExportFileTarget(targetPath); + if (preWriteFileError) { + return preWriteFileError; + } + + const writeError = await writeStatsExportFileAtomically( + cwd, + outputDirectory, + targetPath, + content, + ); + if (writeError) { + return writeError; + } + + return asMessage( + t('Token usage exported to {{format}}: {{path}}', { + format: parsed.format.toUpperCase(), + path: path.relative(cwd, targetPath), + }), + ); + } catch (error) { + return asMessage( + t('Failed to export token usage stats: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), + 'error', + ); + } +} export const statsCommand: SlashCommand = { name: 'stats', @@ -22,6 +636,7 @@ export const statsCommand: SlashCommand = { get description() { return t('Show usage statistics dashboard.'); }, + argumentHint: '[model|tools|daily|monthly|export]', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: ( @@ -150,5 +765,53 @@ export const statsCommand: SlashCommand = { ); }, }, + { + name: 'daily', + altNames: ['day'], + get description() { + return t('Show daily token usage statistics.'); + }, + argumentHint: '[YYYY-MM-DD]', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: (context: CommandContext, args: string) => + showTokenUsageStats(context, 'day', args), + }, + { + name: 'monthly', + altNames: ['month'], + get description() { + return t('Show monthly token usage statistics.'); + }, + argumentHint: '[YYYY-MM]', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: (context: CommandContext, args: string) => + showTokenUsageStats(context, 'month', args), + }, + { + name: 'export', + get description() { + return t('Export token usage statistics to CSV or JSON.'); + }, + argumentHint: + ' [date|month] [--format csv|json] [--output path]', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const result = await writeStatsExport(context, args); + if (context.executionMode !== 'interactive') { + return result; + } + addInteractiveMessage( + context, + result.content, + result.messageType === 'error' ? MessageType.ERROR : MessageType.INFO, + ); + }, + }, ], }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ff98f3f9637..c7866317c29 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -187,6 +187,27 @@ export * from './services/sessionRecap.js'; export * from './services/sessionService.js'; export * from './services/sessionTitle.js'; export * from './services/sleepInhibitor.js'; +// Named exports keep @internal test helpers out of the barrel. +export { + apiResponseEventToTokenUsageRecord, + exportTokenUsageSummary, + formatTokenUsageSummaryAsCsv, + formatTokenUsageSummaryAsJson, + getTokenUsageFilePath, + queryTokenUsage, + recordTokenUsageFromApiResponse, + recordTokenUsageFromApiResponseBestEffort, +} from './services/tokenUsageService.js'; +export type { + TokenUsageExportFormat, + TokenUsageExportOptions, + TokenUsageGroupSummary, + TokenUsagePeriod, + TokenUsageQuery, + TokenUsageRecord, + TokenUsageSummary, + TokenUsageTotals, +} from './services/tokenUsageService.js'; export * from './services/worktreeSessionService.js'; export { stripTerminalControlSequences, diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts new file mode 100644 index 00000000000..ddfa7a26dd5 --- /dev/null +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -0,0 +1,812 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { GenerateContentResponseUsageMetadata } from '@google/genai'; +import { AuthType } from '../core/contentGenerator.js'; +import { Storage } from '../config/storage.js'; +import { makeFakeConfig } from '../test-utils/config.js'; +import { ApiResponseEvent } from '../telemetry/types.js'; +import { setDebugLogSession } from '../utils/debugLogger.js'; +import * as jsonl from '../utils/jsonl-utils.js'; +import { + __overrideNowForTesting, + apiResponseEventToTokenUsageRecord, + exportTokenUsageSummary, + formatTokenUsageSummaryAsCsv, + getTokenUsageFilePath, + queryTokenUsage, + recordTokenUsageFromApiResponse, + recordTokenUsageFromApiResponseBestEffort, + resetTokenUsageFailureLogging, +} from './tokenUsageService.js'; + +describe('tokenUsageService', () => { + let tempDir: string; + let originalRuntimeDir: string | undefined; + let originalDebugLogFileEnv: string | undefined; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-25T10:00:00.000Z')); + originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + originalDebugLogFileEnv = process.env['QWEN_DEBUG_LOG_FILE']; + tempDir = await mkdtemp(path.join(tmpdir(), 'qwen-token-usage-')); + process.env['QWEN_RUNTIME_DIR'] = tempDir; + }); + + afterEach(async () => { + vi.useRealTimers(); + setDebugLogSession(null); + resetTokenUsageFailureLogging(); + if (originalRuntimeDir === undefined) { + delete process.env['QWEN_RUNTIME_DIR']; + } else { + process.env['QWEN_RUNTIME_DIR'] = originalRuntimeDir; + } + if (originalDebugLogFileEnv === undefined) { + delete process.env['QWEN_DEBUG_LOG_FILE']; + } else { + process.env['QWEN_DEBUG_LOG_FILE'] = originalDebugLogFileEnv; + } + Storage.setRuntimeBaseDir(null); + await rm(tempDir, { recursive: true, force: true }); + }); + + function createEvent( + model: string, + promptId: string, + usageData: GenerateContentResponseUsageMetadata, + options: { + timestamp?: string; + authType?: string; + responseId?: string; + subagentName?: string; + durationMs?: number; + } = {}, + ): ApiResponseEvent { + const event = new ApiResponseEvent( + options.responseId ?? `${promptId}-response`, + model, + options.durationMs ?? 100, + promptId, + options.authType ?? AuthType.USE_GEMINI, + usageData, + undefined, + options.subagentName, + ); + event['event.timestamp'] = options.timestamp ?? '2026-05-25T10:00:00.000Z'; + return event; + } + + it('maps an API response event to a privacy-preserving usage record', () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + const event = createEvent( + 'qwen-model', + 'prompt-1', + { + promptTokenCount: 10, + candidatesTokenCount: 20, + cachedContentTokenCount: 3, + thoughtsTokenCount: 5, + totalTokenCount: 35, + }, + { + authType: AuthType.QWEN_OAUTH, + subagentName: 'agent-a', + }, + ); + + const record = apiResponseEventToTokenUsageRecord(config, event); + + expect(record).toMatchObject({ + schemaVersion: 1, + timestamp: '2026-05-25T10:00:00.000Z', + localDate: '2026-05-25', + localMonth: '2026-05', + sessionId: 'session-1', + model: 'qwen-model', + authType: AuthType.QWEN_OAUTH, + source: 'agent-a', + inputTokens: 10, + outputTokens: 20, + cachedTokens: 3, + thoughtsTokens: 5, + totalTokens: 35, + apiDurationMs: 100, + }); + expect(record).not.toHaveProperty('promptId'); + expect(record).not.toHaveProperty('responseId'); + expect(record).not.toHaveProperty('projectRoot'); + expect(record).not.toHaveProperty('response_text'); + }); + + it('uses current local date when API timestamps are missing or invalid', () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + const invalidTimestampEvent = createEvent('model-a', 'prompt-1', { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }); + invalidTimestampEvent['event.timestamp'] = 'not-a-date'; + const missingTimestampEvent = createEvent('model-b', 'prompt-2', { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }); + Reflect.deleteProperty(missingTimestampEvent, 'event.timestamp'); + + const invalidTimestampRecord = apiResponseEventToTokenUsageRecord( + config, + invalidTimestampEvent, + ); + const missingTimestampRecord = apiResponseEventToTokenUsageRecord( + config, + missingTimestampEvent, + ); + + expect(invalidTimestampRecord.localDate).toBe('2026-05-25'); + expect(invalidTimestampRecord.localMonth).toBe('2026-05'); + expect(missingTimestampRecord.timestamp).toBe('2026-05-25T10:00:00.000Z'); + expect(missingTimestampRecord.localDate).toBe('2026-05-25'); + expect(missingTimestampRecord.localMonth).toBe('2026-05'); + }); + + it('persists API usage to monthly JSONL and aggregates daily totals', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + + await recordTokenUsageFromApiResponse( + config, + createEvent('model-a', 'prompt-1', { + promptTokenCount: 10, + candidatesTokenCount: 20, + cachedContentTokenCount: 5, + thoughtsTokenCount: 2, + totalTokenCount: 32, + }), + ); + await recordTokenUsageFromApiResponse( + config, + createEvent( + 'model-b', + 'prompt-2', + { + promptTokenCount: 7, + candidatesTokenCount: 8, + cachedContentTokenCount: 1, + thoughtsTokenCount: 0, + totalTokenCount: 15, + }, + { + authType: AuthType.USE_VERTEX_AI, + timestamp: '2026-05-25T12:00:00.000Z', + }, + ), + ); + await recordTokenUsageFromApiResponse( + config, + createEvent( + 'model-a', + 'prompt-3', + { + promptTokenCount: 100, + candidatesTokenCount: 100, + totalTokenCount: 200, + }, + { + timestamp: '2026-05-26T12:00:00.000Z', + }, + ), + ); + + const fileContent = await readFile( + getTokenUsageFilePath('2026-05'), + 'utf-8', + ); + expect(fileContent.trim().split('\n')).toHaveLength(3); + + const summary = await queryTokenUsage({ + period: 'day', + value: '2026-05-25', + }); + + expect(summary.totals).toMatchObject({ + requests: 2, + inputTokens: 17, + outputTokens: 28, + cachedTokens: 6, + thoughtsTokens: 2, + totalTokens: 47, + apiDurationMs: 200, + }); + expect(summary.byModel.map((group) => group.key)).toEqual([ + 'model-a', + 'model-b', + ]); + expect(summary.byAuthType.map((group) => group.key)).toEqual([ + AuthType.USE_GEMINI, + AuthType.USE_VERTEX_AI, + ]); + }); + + it('swallows best-effort write errors and surfaces non-ENOENT failures', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + const event = createEvent('model-a', 'prompt-1', { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }); + const error = Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + const writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); + const stderrSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + expect(() => + recordTokenUsageFromApiResponseBestEffort(config, event), + ).not.toThrow(); + + expect(writeSpy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(stderrSpy).toHaveBeenCalledWith( + '[token-usage] Write failed (ENOSPC):', + 'disk full', + ); + }); + } finally { + writeSpy.mockRestore(); + stderrSpy.mockRestore(); + } + }); + + it('swallows synchronous best-effort record conversion errors', () => { + const config = { + getSessionId: () => { + throw Object.assign(new Error('session unavailable'), { + code: 'EACCES', + }); + }, + } as unknown as ReturnType; + const event = createEvent('model-a', 'prompt-1', { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }); + const writeSpy = vi.spyOn(jsonl, 'writeLine'); + const stderrSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + expect(() => + recordTokenUsageFromApiResponseBestEffort(config, event), + ).not.toThrow(); + + expect(writeSpy).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith( + '[token-usage] Write failed (EACCES):', + 'session unavailable', + ); + } finally { + writeSpy.mockRestore(); + stderrSpy.mockRestore(); + } + }); + + it('suppresses repeated console.error for same error code within 60s cooldown, re-fires after cooldown', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + const event = createEvent('model-a', 'prompt-1', { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }); + const error = Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + + let fakeNow = 1000000; + __overrideNowForTesting(() => fakeNow); + + const stderrSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + // --- First failure: should log --- + let writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); + try { + recordTokenUsageFromApiResponseBestEffort(config, event); + await vi.waitFor(() => { + expect(stderrSpy).toHaveBeenCalledTimes(1); + }); + expect(stderrSpy).toHaveBeenCalledWith( + '[token-usage] Write failed (ENOSPC):', + 'disk full', + ); + } finally { + writeSpy.mockRestore(); + } + + // --- Second failure, same code, within cooldown: suppressed --- + writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); + try { + recordTokenUsageFromApiResponseBestEffort(config, event); + await vi.runAllTimers(); + // Still only 1 call — the second was suppressed + expect(stderrSpy).toHaveBeenCalledTimes(1); + } finally { + writeSpy.mockRestore(); + } + + const eaccesError = Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(eaccesError); + try { + recordTokenUsageFromApiResponseBestEffort(config, event); + await vi.waitFor(() => { + expect(stderrSpy).toHaveBeenCalledTimes(2); + }); + expect(stderrSpy).toHaveBeenCalledWith( + '[token-usage] Write failed (EACCES):', + 'permission denied', + ); + } finally { + writeSpy.mockRestore(); + } + + // --- Advance time past cooldown: should log again with suppression count --- + fakeNow += 61_000; + writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); + try { + recordTokenUsageFromApiResponseBestEffort(config, event); + await vi.waitFor(() => { + expect(stderrSpy).toHaveBeenCalledTimes(3); + }); + expect(stderrSpy).toHaveBeenLastCalledWith( + '[token-usage] Write failed (ENOSPC):', + 'disk full (1 similar suppressed since last log)', + ); + } finally { + writeSpy.mockRestore(); + } + + stderrSpy.mockRestore(); + }); + + it('does not surface best-effort ENOENT write failures to stderr', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + const event = createEvent('model-a', 'prompt-1', { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }); + const error = Object.assign(new Error('missing directory'), { + code: 'ENOENT', + }); + const writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); + const stderrSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + try { + recordTokenUsageFromApiResponseBestEffort(config, event); + + expect(writeSpy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(writeSpy).toHaveBeenCalledTimes(1); + }); + expect(stderrSpy).not.toHaveBeenCalled(); + } finally { + writeSpy.mockRestore(); + stderrSpy.mockRestore(); + } + }); + + it('aggregates monthly model, auth type, model/auth, and source groups', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + + await recordTokenUsageFromApiResponse( + config, + createEvent( + 'model-a', + 'prompt-1', + { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }, + { + authType: AuthType.USE_GEMINI, + subagentName: 'agent-a', + }, + ), + ); + await recordTokenUsageFromApiResponse( + config, + createEvent( + 'model-a', + 'prompt-2', + { + promptTokenCount: 4, + candidatesTokenCount: 5, + totalTokenCount: 9, + }, + { + authType: AuthType.USE_VERTEX_AI, + subagentName: 'agent-a', + }, + ), + ); + await recordTokenUsageFromApiResponse( + config, + createEvent( + 'model-b', + 'prompt-3', + { + promptTokenCount: 6, + candidatesTokenCount: 7, + totalTokenCount: 13, + }, + { + authType: AuthType.USE_GEMINI, + }, + ), + ); + + const summary = await queryTokenUsage({ + period: 'month', + value: '2026-05', + }); + + expect(summary.totals.totalTokens).toBe(25); + expect(summary.byModel).toEqual([ + expect.objectContaining({ key: 'model-b', totalTokens: 13 }), + expect.objectContaining({ key: 'model-a', totalTokens: 12 }), + ]); + expect(summary.byAuthType).toEqual([ + expect.objectContaining({ key: AuthType.USE_GEMINI, totalTokens: 16 }), + expect.objectContaining({ key: AuthType.USE_VERTEX_AI, totalTokens: 9 }), + ]); + expect(summary.byModelAndAuthType).toEqual([ + expect.objectContaining({ + key: `model-b|${AuthType.USE_GEMINI}`, + model: 'model-b', + authType: AuthType.USE_GEMINI, + totalTokens: 13, + }), + expect.objectContaining({ + key: `model-a|${AuthType.USE_VERTEX_AI}`, + totalTokens: 9, + }), + expect.objectContaining({ + key: `model-a|${AuthType.USE_GEMINI}`, + totalTokens: 3, + }), + ]); + expect(summary.bySource).toEqual([ + expect.objectContaining({ key: 'main', totalTokens: 13 }), + expect.objectContaining({ key: 'agent-a', totalTokens: 12 }), + ]); + }); + + it('falls back to component totals when API total is missing', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + + await recordTokenUsageFromApiResponse( + config, + createEvent('model-a', 'prompt-1', { + promptTokenCount: 10, + candidatesTokenCount: 20, + cachedContentTokenCount: 5, + thoughtsTokenCount: 7, + }), + ); + + const summary = await queryTokenUsage({ + period: 'day', + value: '2026-05-25', + }); + + expect(summary.totals.totalTokens).toBe(37); + expect(summary.totals.cachedTokens).toBe(5); + }); + + it('uses cached tokens as fallback input when prompt tokens are missing', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + + await recordTokenUsageFromApiResponse( + config, + createEvent('model-a', 'prompt-1', { + promptTokenCount: 0, + candidatesTokenCount: 20, + cachedContentTokenCount: 5, + thoughtsTokenCount: 7, + }), + ); + + const summary = await queryTokenUsage({ + period: 'day', + value: '2026-05-25', + }); + + expect(summary.totals.totalTokens).toBe(32); + expect(summary.totals.inputTokens).toBe(5); + expect(summary.totals.cachedTokens).toBe(5); + }); + + it('returns empty summaries for missing usage files', async () => { + const summary = await queryTokenUsage({ + period: 'month', + value: '2026-04', + }); + + expect(summary.totals).toEqual({ + requests: 0, + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + thoughtsTokens: 0, + totalTokens: 0, + apiDurationMs: 0, + }); + expect(summary.byModel).toEqual([]); + }); + + it('surfaces token usage read failures instead of returning zero totals', async () => { + const error = Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + const readSpy = vi.spyOn(jsonl, 'read').mockRejectedValueOnce(error); + + try { + await expect( + queryTokenUsage({ period: 'month', value: '2026-05' }), + ).rejects.toThrow('permission denied'); + expect(readSpy).toHaveBeenCalledWith(getTokenUsageFilePath('2026-05'), { + throwOnNonEnoentError: true, + }); + } finally { + readSpy.mockRestore(); + } + }); + + it('tolerates malformed JSONL lines while querying', async () => { + vi.useRealTimers(); + process.env['QWEN_DEBUG_LOG_FILE'] = '1'; + const filePath = getTokenUsageFilePath('2026-05'); + const sessionId = 'token-usage-read-test'; + setDebugLogSession({ getSessionId: () => sessionId }); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile( + filePath, + [ + '{"schemaVersion":1,"id":"ok","timestamp":"2026-05-25T00:00:00.000Z","localDate":"2026-05-25","localMonth":"2026-05","sessionId":"s","model":"model-a","authType":"gemini","source":"main","inputTokens":1,"outputTokens":2,"cachedTokens":0,"thoughtsTokens":0,"totalTokens":3,"apiDurationMs":4}', + '{"schemaVersion":1,"timestamp":"2026-05-25T00:00:00.000Z","localDate":"2026-05-25","localMonth":"2026-05","sessionId":"s","model":"model-a","authType":"gemini","source":"main","inputTokens":100,"outputTokens":100,"cachedTokens":0,"thoughtsTokens":0,"totalTokens":200,"apiDurationMs":4}', + '{"schemaVersion":1,"id":"missing-session","timestamp":"2026-05-25T00:00:00.000Z","localDate":"2026-05-25","localMonth":"2026-05","model":"model-a","authType":"gemini","source":"main","inputTokens":100,"outputTokens":100,"cachedTokens":0,"thoughtsTokens":0,"totalTokens":200,"apiDurationMs":4}', + '{"schemaVersion":1,"id":"invalid"}', + 'not-json', + ].join('\n'), + 'utf-8', + ); + + const summary = await queryTokenUsage({ + period: 'day', + value: '2026-05-25', + }); + + expect(summary.totals.totalTokens).toBe(3); + expect(summary.totals.requests).toBe(1); + await vi.waitFor(async () => { + const log = await readFile(Storage.getDebugLogPath(sessionId), 'utf-8'); + expect(log).toContain(`Dropped 3/4 invalid record(s) from ${filePath}`); + }); + }); + + it('reads older compatible schema versions', async () => { + const filePath = getTokenUsageFilePath('2026-05'); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile( + filePath, + [ + '{"schemaVersion":1,"id":"ok","timestamp":"2026-05-25T00:00:00.000Z","localDate":"2026-05-25","localMonth":"2026-05","sessionId":"s","model":"model-a","authType":"gemini","source":"main","inputTokens":1,"outputTokens":2,"cachedTokens":0,"thoughtsTokens":0,"totalTokens":3,"apiDurationMs":4}', + '{"schemaVersion":2,"id":"future","timestamp":"2026-05-25T00:00:00.000Z","localDate":"2026-05-25","localMonth":"2026-05","sessionId":"s","model":"model-a","authType":"gemini","source":"main","inputTokens":100,"outputTokens":100,"cachedTokens":0,"thoughtsTokens":0,"totalTokens":200,"apiDurationMs":4}', + ].join('\n'), + 'utf-8', + ); + + const summary = await queryTokenUsage({ + period: 'day', + value: '2026-05-25', + }); + + expect(summary.totals.totalTokens).toBe(3); + expect(summary.totals.requests).toBe(1); + }); + + it('exports summaries as JSON and escaped CSV', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + await recordTokenUsageFromApiResponse( + config, + createEvent( + '=cmd|quoted', + 'prompt-1', + { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }, + { + authType: 'auth"quoted', + }, + ), + ); + + const json = await exportTokenUsageSummary({ + period: 'day', + value: '2026-05-25', + format: 'json', + }); + expect(JSON.parse(json)).toMatchObject({ + period: 'day', + value: '2026-05-25', + totals: { totalTokens: 3 }, + }); + expect(JSON.parse(json)).not.toHaveProperty('coordination'); + + const csv = formatTokenUsageSummaryAsCsv( + await queryTokenUsage({ period: 'day', value: '2026-05-25' }), + ); + expect(csv).toContain('day,2026-05-25,total,total,,,,1,1,2,0,0,3,100'); + expect(csv).toContain( + "day,2026-05-25,model,'=cmd|quoted,'=cmd|quoted,,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + 'day,2026-05-25,auth_type,"auth""quoted",,"auth""quoted",,1,1,2,0,0,3,100', + ); + }); + + it('escapes formula-like CSV fields after leading whitespace', () => { + const group = (key: string) => ({ + key, + model: key, + requests: 1, + inputTokens: 1, + outputTokens: 2, + cachedTokens: 0, + thoughtsTokens: 0, + totalTokens: 3, + apiDurationMs: 100, + }); + const csv = formatTokenUsageSummaryAsCsv({ + period: 'day', + value: '2026-05-25', + generatedAt: '2026-05-25T10:00:00.000Z', + totals: { + requests: 9, + inputTokens: 9, + outputTokens: 18, + cachedTokens: 0, + thoughtsTokens: 0, + totalTokens: 27, + apiDurationMs: 900, + }, + byModel: [ + '=SUM(A1)', + ' =SUM(A1)', + '+SUM(A1)', + ' +SUM(A1)', + '-SUM(A1)', + ' -SUM(A1)', + '@SUM(A1)', + ' @SUM(A1)', + '\tSUM(A1)', + ].map(group), + byAuthType: [], + byModelAndAuthType: [], + bySource: [], + }); + + expect(csv).toContain( + "day,2026-05-25,model,'=SUM(A1),'=SUM(A1),,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + "day,2026-05-25,model,' =SUM(A1),' =SUM(A1),,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + "day,2026-05-25,model,'+SUM(A1),'+SUM(A1),,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + "day,2026-05-25,model,' +SUM(A1),' +SUM(A1),,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + "day,2026-05-25,model,'-SUM(A1),'-SUM(A1),,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + "day,2026-05-25,model,' -SUM(A1),' -SUM(A1),,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + "day,2026-05-25,model,'@SUM(A1),'@SUM(A1),,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + "day,2026-05-25,model,' @SUM(A1),' @SUM(A1),,,1,1,2,0,0,3,100", + ); + expect(csv).toContain( + "day,2026-05-25,model,'\tSUM(A1),'\tSUM(A1),,,1,1,2,0,0,3,100", + ); + }); + + it('persists best-effort records asynchronously without blocking callers', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + + recordTokenUsageFromApiResponseBestEffort( + config, + createEvent('model-a', 'prompt-1', { + promptTokenCount: 1, + candidatesTokenCount: 2, + totalTokenCount: 3, + }), + ); + + const fileContent = await vi.waitFor(() => + readFile(getTokenUsageFilePath('2026-05'), 'utf-8'), + ); + expect(fileContent).toContain('"model":"model-a"'); + }); + + it('validates period values', async () => { + await expect( + queryTokenUsage({ period: 'day', value: '2026-05' }), + ).rejects.toThrow('Expected YYYY-MM-DD'); + await expect( + queryTokenUsage({ period: 'day', value: '2026-02-29' }), + ).rejects.toThrow('Expected YYYY-MM-DD'); + await expect( + queryTokenUsage({ period: 'day', value: '2024-02-29' }), + ).resolves.toMatchObject({ value: '2024-02-29' }); + await expect( + queryTokenUsage({ period: 'month', value: '2026-05-25' }), + ).rejects.toThrow('Expected YYYY-MM'); + await expect( + queryTokenUsage({ period: 'month', value: '2026-13' }), + ).rejects.toThrow('Expected YYYY-MM'); + expect(() => getTokenUsageFilePath('2026-00')).toThrow('Expected YYYY-MM'); + }); +}); diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts new file mode 100644 index 00000000000..fe453395599 --- /dev/null +++ b/packages/core/src/services/tokenUsageService.ts @@ -0,0 +1,568 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { Config } from '../config/config.js'; +import { Storage } from '../config/storage.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import * as jsonl from '../utils/jsonl-utils.js'; +import type { ApiResponseEvent } from '../telemetry/types.js'; +import { MAIN_SOURCE } from '../utils/subagentNameContext.js'; + +const debugLogger = createDebugLogger('TOKEN_USAGE'); +const USAGE_DIR_NAME = 'usage'; +const FILE_PREFIX = 'token-usage-'; +const FILE_EXTENSION = '.jsonl'; +const SCHEMA_VERSION = 1; +const UNKNOWN_AUTH_TYPE = 'unknown'; + +export type TokenUsagePeriod = 'day' | 'month'; +export type TokenUsageExportFormat = 'json' | 'csv'; + +export interface TokenUsageRecord { + schemaVersion: typeof SCHEMA_VERSION; + id: string; + timestamp: string; + /** + * Calendar date in the local timezone of the process that wrote this record. + * Records written from different timezones keep their original local bucket. + */ + localDate: string; + /** + * Calendar month in the local timezone of the process that wrote this record. + * Records written from different timezones keep their original local bucket. + */ + localMonth: string; + sessionId: string; + model: string; + authType: string; + source: string; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + thoughtsTokens: number; + totalTokens: number; + /** + * End-to-end API response duration from telemetry. This is not generation + * duration, TTFT, or TPS; those remain owned by #4252's timing surface. + */ + apiDurationMs: number; +} + +export interface TokenUsageTotals { + requests: number; + inputTokens: number; + outputTokens: number; + cachedTokens: number; + thoughtsTokens: number; + totalTokens: number; + apiDurationMs: number; +} + +export interface TokenUsageGroupSummary extends TokenUsageTotals { + key: string; + model?: string; + authType?: string; + source?: string; +} + +export interface TokenUsageSummary { + period: TokenUsagePeriod; + value: string; + generatedAt: string; + totals: TokenUsageTotals; + byModel: TokenUsageGroupSummary[]; + byAuthType: TokenUsageGroupSummary[]; + byModelAndAuthType: TokenUsageGroupSummary[]; + bySource: TokenUsageGroupSummary[]; +} + +export interface TokenUsageQuery { + period: TokenUsagePeriod; + value?: string; +} + +export interface TokenUsageExportOptions extends TokenUsageQuery { + format: TokenUsageExportFormat; +} + +function createEmptyTotals(): TokenUsageTotals { + return { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cachedTokens: 0, + thoughtsTokens: 0, + totalTokens: 0, + apiDurationMs: 0, + }; +} + +function addRecordToTotals( + totals: TokenUsageTotals, + record: TokenUsageRecord, +): void { + totals.requests += 1; + totals.inputTokens += record.inputTokens; + totals.outputTokens += record.outputTokens; + totals.cachedTokens += record.cachedTokens; + totals.thoughtsTokens += record.thoughtsTokens; + totals.totalTokens += record.totalTokens; + totals.apiDurationMs += record.apiDurationMs; +} + +function getLocalDateParts(date: Date): { date: string; month: string } { + const year = date.getFullYear(); + const monthNumber = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return { + date: `${year}-${monthNumber}-${day}`, + month: `${year}-${monthNumber}`, + }; +} + +function currentPeriodValue(period: TokenUsagePeriod): string { + const parts = getLocalDateParts(new Date()); + return period === 'day' ? parts.date : parts.month; +} + +function isValidDay(value: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return false; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + if (month < 1 || month > 12 || day < 1) { + return false; + } + const daysInMonth = [ + 31, + isLeapYear(year) ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + return day <= daysInMonth[month - 1]!; +} + +function isValidMonth(value: string): boolean { + const match = /^(\d{4})-(\d{2})$/.exec(value); + if (!match) { + return false; + } + const month = Number(match[2]); + return month >= 1 && month <= 12; +} + +function isLeapYear(year: number): boolean { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +} + +function normalizePeriodValue( + period: TokenUsagePeriod, + value?: string, +): string { + const normalized = value?.trim() || currentPeriodValue(period); + const isValid = + period === 'day' ? isValidDay(normalized) : isValidMonth(normalized); + if (!isValid) { + throw new Error( + `Invalid ${period} value "${normalized}". Expected ${ + period === 'day' ? 'YYYY-MM-DD' : 'YYYY-MM' + }.`, + ); + } + return normalized; +} + +function usageDir(): string { + return path.join(Storage.getRuntimeBaseDir(), USAGE_DIR_NAME); +} + +export function getTokenUsageFilePath(month: string): string { + if (!isValidMonth(month)) { + throw new Error(`Invalid month value "${month}". Expected YYYY-MM.`); + } + return path.join(usageDir(), `${FILE_PREFIX}${month}${FILE_EXTENSION}`); +} + +function toNonNegativeInteger(value: number | undefined): number { + if (!Number.isFinite(value) || value === undefined || value <= 0) { + return 0; + } + return Math.trunc(value); +} + +function calculateInputTokens(event: ApiResponseEvent): number { + const inputTokens = toNonNegativeInteger(event.input_token_count); + if (inputTokens > 0) { + return inputTokens; + } + // When the API omits prompt tokens, cached tokens are only a lower-bound + // proxy for input usage and can undercount the actual input. + return toNonNegativeInteger(event.cached_content_token_count); +} + +function calculateTotalTokens(event: ApiResponseEvent): number { + const total = toNonNegativeInteger(event.total_token_count); + if (total > 0) { + return total; + } + return ( + calculateInputTokens(event) + + toNonNegativeInteger(event.output_token_count) + + toNonNegativeInteger(event.thoughts_token_count) + ); +} + +export function apiResponseEventToTokenUsageRecord( + config: Config, + event: ApiResponseEvent, +): TokenUsageRecord { + const timestamp = event['event.timestamp'] || new Date().toISOString(); + const date = new Date(timestamp); + const localParts = getLocalDateParts( + Number.isNaN(date.getTime()) ? new Date() : date, + ); + return { + schemaVersion: SCHEMA_VERSION, + id: randomUUID(), + timestamp, + localDate: localParts.date, + localMonth: localParts.month, + sessionId: config.getSessionId(), + model: event.model || 'unknown', + authType: event.auth_type || UNKNOWN_AUTH_TYPE, + source: event.subagent_name || MAIN_SOURCE, + inputTokens: calculateInputTokens(event), + outputTokens: toNonNegativeInteger(event.output_token_count), + cachedTokens: toNonNegativeInteger(event.cached_content_token_count), + thoughtsTokens: toNonNegativeInteger(event.thoughts_token_count), + totalTokens: calculateTotalTokens(event), + apiDurationMs: toNonNegativeInteger(event.duration_ms), + }; +} + +function isTokenUsageRecord(value: unknown): value is TokenUsageRecord { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const record = value as Partial; + return ( + typeof record.id === 'string' && + typeof record.sessionId === 'string' && + typeof record.schemaVersion === 'number' && + Number.isInteger(record.schemaVersion) && + record.schemaVersion > 0 && + record.schemaVersion <= SCHEMA_VERSION && + typeof record.timestamp === 'string' && + typeof record.localDate === 'string' && + typeof record.localMonth === 'string' && + typeof record.model === 'string' && + typeof record.authType === 'string' && + typeof record.source === 'string' && + typeof record.inputTokens === 'number' && + typeof record.outputTokens === 'number' && + typeof record.cachedTokens === 'number' && + typeof record.thoughtsTokens === 'number' && + typeof record.totalTokens === 'number' && + typeof record.apiDurationMs === 'number' + ); +} + +async function readRecordsForMonth(month: string): Promise { + const filePath = getTokenUsageFilePath(month); + const records = await jsonl.read(filePath, { + throwOnNonEnoentError: true, + }); + const valid: TokenUsageRecord[] = []; + let dropped = 0; + for (const record of records) { + if (isTokenUsageRecord(record)) { + valid.push(record); + } else { + dropped++; + } + } + if (dropped > 0) { + debugLogger.warn( + `Dropped ${dropped}/${records.length} invalid record(s) from ${filePath}`, + ); + } + return valid; +} + +function summarizeRecords( + period: TokenUsagePeriod, + value: string, + records: TokenUsageRecord[], +): TokenUsageSummary { + const totals = createEmptyTotals(); + const byModel = new Map(); + const byAuthType = new Map(); + const byModelAndAuthType = new Map(); + const bySource = new Map(); + + const getGroup = ( + map: Map, + key: string, + fields: Pick, + ): TokenUsageGroupSummary => { + let group = map.get(key); + if (!group) { + group = { + key, + ...fields, + ...createEmptyTotals(), + }; + map.set(key, group); + } + return group; + }; + + for (const record of records) { + addRecordToTotals(totals, record); + addRecordToTotals( + getGroup(byModel, record.model, { model: record.model }), + record, + ); + addRecordToTotals( + getGroup(byAuthType, record.authType, { authType: record.authType }), + record, + ); + addRecordToTotals( + getGroup(byModelAndAuthType, `${record.model}|${record.authType}`, { + model: record.model, + authType: record.authType, + }), + record, + ); + addRecordToTotals( + getGroup(bySource, record.source, { source: record.source }), + record, + ); + } + + const sortGroups = ( + groups: Iterable, + ): TokenUsageGroupSummary[] => + [...groups].sort((a, b) => { + if (b.totalTokens !== a.totalTokens) { + return b.totalTokens - a.totalTokens; + } + return a.key.localeCompare(b.key); + }); + + return { + period, + value, + generatedAt: new Date().toISOString(), + totals, + byModel: sortGroups(byModel.values()), + byAuthType: sortGroups(byAuthType.values()), + byModelAndAuthType: sortGroups(byModelAndAuthType.values()), + bySource: sortGroups(bySource.values()), + }; +} + +export async function recordTokenUsageFromApiResponse( + config: Config, + event: ApiResponseEvent, +): Promise { + const record = apiResponseEventToTokenUsageRecord(config, event); + await jsonl.writeLine(getTokenUsageFilePath(record.localMonth), record); +} + +const lastLoggedTimeByCode = new Map(); +const suppressedCountByCode = new Map(); +const TOKEN_USAGE_FAILURE_LOG_COOLDOWN_MS = 60_000; +let _now: () => number = () => Date.now(); + +/** @internal Override the time source for testing cooldown behavior. */ +export function __overrideNowForTesting(fn: () => number): void { + _now = fn; +} + +function logTokenUsageWriteFailure(error: unknown): void { + debugLogger.warn('Failed to record token usage:', error); + const code = (error as NodeJS.ErrnoException).code; + if (code && code !== 'ENOENT') { + const now = _now(); + const lastTime = lastLoggedTimeByCode.get(code) ?? 0; + if (now - lastTime > TOKEN_USAGE_FAILURE_LOG_COOLDOWN_MS) { + lastLoggedTimeByCode.set(code, now); + const suppressedCount = suppressedCountByCode.get(code) ?? 0; + suppressedCountByCode.delete(code); + const message = error instanceof Error ? error.message : String(error); + // eslint-disable-next-line no-console -- surface persistent local write failures outside debug mode + console.error( + `[token-usage] Write failed (${code}):`, + suppressedCount > 0 + ? `${message} (${suppressedCount} similar suppressed since last log)` + : message, + ); + } else { + suppressedCountByCode.set( + code, + (suppressedCountByCode.get(code) ?? 0) + 1, + ); + } + } +} + +/** @internal Reset token usage failure rate-limiting state. For testing only. */ +export function resetTokenUsageFailureLogging(): void { + lastLoggedTimeByCode.clear(); + suppressedCountByCode.clear(); + _now = () => Date.now(); +} + +export function recordTokenUsageFromApiResponseBestEffort( + config: Config, + event: ApiResponseEvent, +): void { + try { + const record = apiResponseEventToTokenUsageRecord(config, event); + void jsonl + .writeLine(getTokenUsageFilePath(record.localMonth), record) + .catch(logTokenUsageWriteFailure); + } catch (error) { + logTokenUsageWriteFailure(error); + } +} + +export async function queryTokenUsage( + query: TokenUsageQuery, +): Promise { + const value = normalizePeriodValue(query.period, query.value); + const month = query.period === 'day' ? value.slice(0, 7) : value; + const records = (await readRecordsForMonth(month)).filter((record) => + query.period === 'day' + ? record.localDate === value + : record.localMonth === value, + ); + return summarizeRecords(query.period, value, records); +} + +function csvEscape(value: string | number | undefined): string { + const stringValue = value === undefined ? '' : String(value); + const trimmed = stringValue.trimStart(); + const sanitized = + /^[=+\-@]/.test(trimmed) || /^[\t\r\n]/.test(stringValue) + ? `'${stringValue}` + : stringValue; + if (/[",\n\r]/.test(sanitized)) { + return `"${sanitized.replace(/"/g, '""')}"`; + } + return sanitized; +} + +function groupRows( + period: TokenUsagePeriod, + value: string, + groupType: string, + groups: TokenUsageGroupSummary[], +): string[][] { + return groups.map((group) => [ + period, + value, + groupType, + group.key, + group.model ?? '', + group.authType ?? '', + group.source ?? '', + String(group.requests), + String(group.inputTokens), + String(group.outputTokens), + String(group.cachedTokens), + String(group.thoughtsTokens), + String(group.totalTokens), + String(group.apiDurationMs), + ]); +} + +export function formatTokenUsageSummaryAsCsv( + summary: TokenUsageSummary, +): string { + const header = [ + 'period', + 'value', + 'group_type', + 'group_key', + 'model', + 'auth_type', + 'source', + 'requests', + 'input_tokens', + 'output_tokens', + 'cached_tokens', + 'thoughts_tokens', + 'total_tokens', + 'api_duration_ms', + ]; + const rows = [ + [ + summary.period, + summary.value, + 'total', + 'total', + '', + '', + '', + String(summary.totals.requests), + String(summary.totals.inputTokens), + String(summary.totals.outputTokens), + String(summary.totals.cachedTokens), + String(summary.totals.thoughtsTokens), + String(summary.totals.totalTokens), + String(summary.totals.apiDurationMs), + ], + ...groupRows(summary.period, summary.value, 'model', summary.byModel), + ...groupRows( + summary.period, + summary.value, + 'auth_type', + summary.byAuthType, + ), + ...groupRows( + summary.period, + summary.value, + 'model_auth_type', + summary.byModelAndAuthType, + ), + ...groupRows(summary.period, summary.value, 'source', summary.bySource), + ]; + + return [ + header.join(','), + ...rows.map((row) => row.map(csvEscape).join(',')), + ].join('\n'); +} + +export function formatTokenUsageSummaryAsJson( + summary: TokenUsageSummary, +): string { + return `${JSON.stringify(summary, null, 2)}\n`; +} + +export async function exportTokenUsageSummary( + options: TokenUsageExportOptions, +): Promise { + const summary = await queryTokenUsage(options); + return options.format === 'json' + ? formatTokenUsageSummaryAsJson(summary) + : `${formatTokenUsageSummaryAsCsv(summary)}\n`; +} diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 5931d291fb4..387a588fc1b 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -63,6 +63,7 @@ import { import * as metrics from './metrics.js'; import { QwenLogger } from './qwen-logger/qwen-logger.js'; import * as sdk from './sdk.js'; +import * as tokenUsageService from '../services/tokenUsageService.js'; import { ToolCallDecision } from './tool-call-decision.js'; import { ApiRequestEvent, @@ -113,6 +114,10 @@ describe('loggers', () => { vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); }); + afterEach(() => { + vi.useRealTimers(); + }); + describe('logChatCompression', () => { beforeEach(() => { vi.spyOn(metrics, 'recordChatCompressionMetrics'); @@ -301,6 +306,10 @@ describe('loggers', () => { vi.spyOn(metrics, 'recordTokenUsageMetrics').mockImplementation( mockMetrics.recordTokenUsageMetrics, ); + vi.spyOn( + tokenUsageService, + 'recordTokenUsageFromApiResponseBestEffort', + ).mockImplementation(() => undefined); }); it('should log an API response with all fields', () => { @@ -364,11 +373,68 @@ describe('loggers', () => { }, 'test-session-id', ); + expect( + tokenUsageService.recordTokenUsageFromApiResponseBestEffort, + ).toHaveBeenCalledWith(mockConfig, event); + }); + + it.each([ + 'prompt_suggestion', + 'forked_query', + 'speculation', + 'side-query:session-title', + ])('does not record token usage for internal prompt_id %s', (promptId) => { + const event = new ApiResponseEvent( + 'test-response-id', + 'test-model', + 100, + promptId, + AuthType.USE_GEMINI, + { + promptTokenCount: 1, + candidatesTokenCount: 2, + }, + ); + + logApiResponse(mockConfig, event); + + expect( + tokenUsageService.recordTokenUsageFromApiResponseBestEffort, + ).not.toHaveBeenCalled(); + }); + + it('does not record token usage when usage statistics are disabled', () => { + const configWithUsageStatsDisabled = { + ...mockConfig, + getUsageStatisticsEnabled: () => false, + } as unknown as Config; + const event = new ApiResponseEvent( + 'test-response-id', + 'test-model', + 100, + 'prompt-id-1', + AuthType.USE_GEMINI, + { + promptTokenCount: 1, + candidatesTokenCount: 2, + }, + ); + + logApiResponse(configWithUsageStatsDisabled, event); + + expect( + tokenUsageService.recordTokenUsageFromApiResponseBestEffort, + ).not.toHaveBeenCalled(); }); }); describe('logApiResponse skips chatRecordingService for internal prompt IDs', () => { - it.each(['prompt_suggestion', 'forked_query', 'speculation'])( + it.each([ + 'prompt_suggestion', + 'forked_query', + 'speculation', + 'side-query:session-title', + ])( 'should not record to chatRecordingService when prompt_id is %s', (promptId) => { const mockRecordUiTelemetryEvent = vi.fn(); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 94965cfc284..6084d3f06bf 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -122,6 +122,7 @@ import type { import type { HookCallEvent } from './types.js'; import type { UiEvent } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js'; +import { recordTokenUsageFromApiResponseBestEffort } from '../services/tokenUsageService.js'; const shouldLogUserPrompts = (config: Config): boolean => config.getTelemetryLogPromptsEnabled(); @@ -467,6 +468,9 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void { } as UiEvent; uiTelemetryService.addEvent(uiEvent, config.getSessionId()); if (!isInternalPromptId(event.prompt_id)) { + if (config.getUsageStatisticsEnabled()) { + recordTokenUsageFromApiResponseBestEffort(config, event); + } config.getChatRecordingService()?.recordUiTelemetryEvent(uiEvent); } QwenLogger.getInstance(config)?.logApiResponseEvent(event); diff --git a/packages/core/src/utils/jsonl-utils.test.ts b/packages/core/src/utils/jsonl-utils.test.ts index 47a12974d0d..ada232da14f 100644 --- a/packages/core/src/utils/jsonl-utils.test.ts +++ b/packages/core/src/utils/jsonl-utils.test.ts @@ -199,6 +199,32 @@ describe('read() / readLines() with malformed lines', () => { expect(await read(path.join(tmpRoot, 'does-not-exist.jsonl'))).toEqual([]); }); + it('can rethrow non-ENOENT read errors for user-visible callers', async () => { + const file = tmpFile('{"a":1}\n'); + const error = Object.assign(new Error('permission denied'), { + code: 'EACCES', + }); + const spy = vi.spyOn(fs, 'createReadStream').mockImplementationOnce(() => { + throw error; + }); + + try { + await expect(read(file, { throwOnNonEnoentError: true })).rejects.toThrow( + 'permission denied', + ); + } finally { + spy.mockRestore(); + } + }); + + it('still returns [] for missing files when rethrowing read errors', async () => { + await expect( + read(path.join(tmpRoot, 'does-not-exist.jsonl'), { + throwOnNonEnoentError: true, + }), + ).resolves.toEqual([]); + }); + it('readLines respects the limit when objects come from recovery', async () => { // Two clean lines, then a glued pair. Asking for 3 should yield 3. const file = tmpFile('{"i":1}\n{"i":2}\n{"i":3}{"i":4}\n{"i":5}\n'); diff --git a/packages/core/src/utils/jsonl-utils.ts b/packages/core/src/utils/jsonl-utils.ts index f04bda21f3f..1a749b747ac 100644 --- a/packages/core/src/utils/jsonl-utils.ts +++ b/packages/core/src/utils/jsonl-utils.ts @@ -31,6 +31,10 @@ import { createDebugLogger } from './debugLogger.js'; const debugLogger = createDebugLogger('JSONL'); +type JsonlReadOptions = { + throwOnNonEnoentError?: boolean; +}; + /** * A map of file paths to mutexes for preventing concurrent writes. */ @@ -206,7 +210,10 @@ export async function readLines( * Reads all lines from a JSONL file. * Returns an array of parsed objects. */ -export async function read(filePath: string): Promise { +export async function read( + filePath: string, + options: JsonlReadOptions = {}, +): Promise { let fileStream: fs.ReadStream | undefined; let rl: readline.Interface | undefined; try { @@ -229,6 +236,9 @@ export async function read(filePath: string): Promise { } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { debugLogger.error(`Error reading ${filePath}:`, error); + if (options.throwOnNonEnoentError) { + throw error; + } } return []; } finally {