From 086d81384b249318acc01e3c762968c9cc06a356 Mon Sep 17 00:00:00 2001 From: sheny Date: Wed, 27 May 2026 11:10:47 +0800 Subject: [PATCH 01/13] feat(stats): expose token usage for cost visibility Persist content-free API token counters and surface daily/monthly summaries plus CSV/JSON export through /stats. Constraint: Issue #4479 requested CLI token visibility with monthly/model breakdowns and export while coordinating with #4252/#4182.\nRejected: Add a separate top-level token command | /stats keeps related statistics in one surface.\nConfidence: high\nScope-risk: moderate\nDirective: Keep TTFT/TPS generation timing and memory diagnostics outside this token-usage surface unless their issues explicitly broaden scope.\nTested: npx vitest run src/services/tokenUsageService.test.ts src/telemetry/loggers.test.ts; npx vitest run src/ui/commands/statsCommand.test.ts src/ui/hooks/useAutoAcceptIndicator.test.ts src/ui/components/AutoAcceptIndicator.test.tsx; npm run check-i18n --workspace=packages/cli; npm run lint --workspace=packages/cli; npm run lint --workspace=packages/core; npm run typecheck; npm run build; git diff --check\nNot-tested: full integration suite --- ...sue-4479-token-usage-stats-coordination.md | 46 + packages/cli/src/i18n/locales/ca.js | 59 +- packages/cli/src/i18n/locales/de.js | 60 +- packages/cli/src/i18n/locales/en.js | 65 ++ packages/cli/src/i18n/locales/fr.js | 62 +- packages/cli/src/i18n/locales/ja.js | 56 +- packages/cli/src/i18n/locales/pt.js | 60 +- packages/cli/src/i18n/locales/ru.js | 62 +- packages/cli/src/i18n/locales/zh-TW.js | 64 +- packages/cli/src/i18n/locales/zh.js | 60 ++ packages/cli/src/i18n/mustTranslateKeys.ts | 11 + .../cli/src/ui/commands/statsCommand.test.ts | 805 +++++++++++++++++- packages/cli/src/ui/commands/statsCommand.ts | 665 ++++++++++++++- .../components/AutoAcceptIndicator.test.tsx | 34 + .../ui/hooks/useAutoAcceptIndicator.test.ts | 51 ++ .../src/ui/hooks/useAutoAcceptIndicator.ts | 37 +- packages/core/src/index.ts | 21 + .../src/services/tokenUsageService.test.ts | 405 +++++++++ .../core/src/services/tokenUsageService.ts | 442 ++++++++++ packages/core/src/telemetry/loggers.test.ts | 68 +- packages/core/src/telemetry/loggers.ts | 4 + 21 files changed, 3096 insertions(+), 41 deletions(-) create mode 100644 docs/design/issue-4479-token-usage-stats-coordination.md create mode 100644 packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx create mode 100644 packages/core/src/services/tokenUsageService.test.ts create mode 100644 packages/core/src/services/tokenUsageService.ts 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..f3236f1cb0e 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -25,6 +25,23 @@ export default { 'Auto mode': 'Mode auto', 'plan mode': 'mode de planificació', 'auto-accept edits': 'acceptació automàtica de canvis', + 'auto mode (classifier-evaluated)': 'mode automàtic (classificador avaluat)', + 'Auto mode enabled.': 'Mode automàtic activat.', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + "Un classificador LLM avalua cada crida d'eina i aprova automàticament les accions segures,", + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + "bloqueja les arriscades. La majoria d'operacions de només lectura i edicions dins del directori actual ometen el", + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + 'classificador per rapidesa. Per sortir: Shift+Tab o /approval-mode default.', + '(This notice will not appear again.)': + '(Aquest avís no tornarà a aparèixer.)', + 'Auto mode temporarily disabled these allow rules': + 'El mode automàtic ha desactivat temporalment aquestes regles de permís', + '(they would bypass the classifier):': '(ometrian el classificador):', + '(from user settings)': "(de la configuració de l'usuari)", + '(session)': '(sessió)', + 'These will be restored when leaving auto mode.': + 'Es restauraran en sortir del mode automàtic.', 'Accepting edits': 'Acceptant canvis', '(shift + tab to cycle)': '(Shift + Tab per canviar)', '(tab to cycle)': '(Tab per canviar)', @@ -489,12 +506,50 @@ 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]', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + 'comprovar les estadístiques de la sessió. Ús: /stats [model|tools|daily|monthly|export]', '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: {{tokens}}': 'Memòria cau: {{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ó:", + '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.", + '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}}", + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', '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..56a52ae67bf 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -27,6 +27,24 @@ export default { 'Auto mode': 'Auto-Modus', 'plan mode': 'Planungsmodus', 'auto-accept edits': 'Änderungen automatisch akzeptieren', + 'auto mode (classifier-evaluated)': 'Automodus (Klassifizierer ausgewertet)', + 'Auto mode enabled.': 'Automodus aktiviert.', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + 'Ein LLM-Klassifizierer bewertet jeden Werkzeugaufruf und genehmigt sichere Aktionen automatisch,', + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + 'blockiert riskante Aktionen. Die meisten Nur-Lese-Operationen und Bearbeitungen im Arbeitsverzeichnis überspringen den', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + 'Klassifizierer aus Geschwindigkeitsgründen. Beenden: Shift+Tab oder /approval-mode default.', + '(This notice will not appear again.)': + '(Dieser Hinweis wird nicht erneut angezeigt.)', + 'Auto mode temporarily disabled these allow rules': + 'Der Automodus hat diese Zulassungsregeln vorübergehend deaktiviert', + '(they would bypass the classifier):': + '(sie würden den Klassifizierer umgehen):', + '(from user settings)': '(aus Benutzereinstellungen)', + '(session)': '(Sitzung)', + 'These will be restored when leaving auto mode.': + 'Diese werden beim Verlassen des Automodus wiederhergestellt.', 'Accepting edits': 'Änderungen werden akzeptiert', '(shift + tab to cycle)': '(Shift + Tab zum Wechseln)', '(tab to cycle)': '(Tab zum Wechseln)', @@ -420,12 +438,50 @@ 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]', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + 'Sitzungsstatistiken prüfen. Verwendung: /stats [model|tools|daily|monthly|export]', '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: {{tokens}}': 'Cache: {{tokens}}', + 'Thoughts: {{tokens}}': 'Gedanken: {{tokens}}', + 'By model:': 'Nach Modell:', + 'By auth type:': 'Nach Authentifizierungstyp:', + 'By model/auth type:': 'Nach Modell/Authentifizierungstyp:', + '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.', + '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}}', + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', '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..5cf9a2e66a3 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -27,6 +27,23 @@ export default { 'Auto mode': 'Auto mode', 'plan mode': 'plan mode', 'auto-accept edits': 'auto-accept edits', + 'auto mode (classifier-evaluated)': 'auto mode (classifier-evaluated)', + 'Auto mode enabled.': 'Auto mode enabled.', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + 'An LLM classifier evaluates each tool call and auto-approves safe actions,', + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + 'blocks risky ones. Most read-only operations and in-cwd edits skip the', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.', + '(This notice will not appear again.)': + '(This notice will not appear again.)', + 'Auto mode temporarily disabled these allow rules': + 'Auto mode temporarily disabled these allow rules', + '(they would bypass the classifier):': '(they would bypass the classifier):', + '(from user settings)': '(from user settings)', + '(session)': '(session)', + 'These will be restored when leaving auto mode.': + 'These will be restored when leaving auto mode.', 'Accepting edits': 'Accepting edits', '(shift + tab to cycle)': '(shift + tab to cycle)', '(tab to cycle)': '(tab to cycle)', @@ -516,11 +533,59 @@ export default { 'Auto Edit': 'Auto Edit', YOLO: 'YOLO', 'toggle vim mode on/off': 'toggle vim mode on/off', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]', 'Show usage statistics dashboard.': 'Show usage statistics dashboard.', 'Show model-specific usage statistics.': '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..5da26f835c1 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -25,6 +25,24 @@ export default { 'Auto mode': 'Mode auto', 'plan mode': 'mode plan', 'auto-accept edits': 'acceptation automatique des modifications', + 'auto mode (classifier-evaluated)': + 'mode automatique (classificateur évalué)', + 'Auto mode enabled.': 'Mode automatique activé.', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + "Un classificateur LLM évalue chaque appel d'outil et approuve automatiquement les actions sûres,", + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + 'bloque les actions risquées. La plupart des opérations en lecture seule et des modifications dans le dossier courant ignorent le', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + 'classificateur pour plus de rapidité. Pour quitter : Shift+Tab ou /approval-mode default.', + '(This notice will not appear again.)': "(Cet avis ne s'affichera plus.)", + 'Auto mode temporarily disabled these allow rules': + "Le mode automatique a temporairement désactivé ces règles d'autorisation", + '(they would bypass the classifier):': + '(elles contourneraient le classificateur) :', + '(from user settings)': '(depuis les paramètres utilisateur)', + '(session)': '(session actuelle)', + 'These will be restored when leaving auto mode.': + 'Elles seront restaurées à la sortie du mode automatique.', 'Accepting edits': 'Acceptation des modifications', '(shift + tab to cycle)': '(Shift + Tab pour cycler)', '(tab to cycle)': '(Tab pour cycler)', @@ -494,12 +512,52 @@ 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]', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + 'vérifier les stats de session. Utilisation : /stats [modèle|outils|daily|monthly|export]', '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: {{tokens}}': 'Cache : {{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 :", + '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.", + '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}}", + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', '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..364aab264d4 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -21,6 +21,22 @@ export default { 'Auto mode': 'Autoモード', 'plan mode': 'プランモード', 'auto-accept edits': '編集を自動承認', + 'auto mode (classifier-evaluated)': '自動モード(分類器評価済み)', + 'Auto mode enabled.': '自動モードが有効になりました。', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + 'LLM 分類器が各ツール呼び出しを評価し、安全な操作を自動承認し、', + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + 'リスクのある操作をブロックします。多くの読み取り専用操作と作業ディレクトリ内の編集は', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + '速度向上のため分類器をスキップします。終了: Shift+Tab または /approval-mode default。', + '(This notice will not appear again.)': '(この通知は再表示されません。)', + 'Auto mode temporarily disabled these allow rules': + '自動モードが次の許可ルールを一時的に無効化しました', + '(they would bypass the classifier):': '(分類器をバイパスするため):', + '(from user settings)': '(ユーザー設定から)', + '(session)': '(セッション)', + 'These will be restored when leaving auto mode.': + '自動モードを終了すると復元されます。', 'Accepting edits': '編集を承認中', '(shift + tab to cycle)': '(Shift + Tab で切り替え)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': @@ -360,10 +376,46 @@ export default { 'Auto (detect from system)': '自動(システムから検出)', 'Auto (detect terminal theme)': '自動(端末テーマを検出)', Auto: '自動', - 'check session stats. Usage: /stats [model|tools]': - 'セッション統計を確認。使い方: /stats [model|tools]', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + 'セッション統計を確認。使い方: /stats [model|tools|daily|monthly|export]', '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: {{tokens}}': 'キャッシュ: {{tokens}}', + 'Thoughts: {{tokens}}': '思考: {{tokens}}', + 'By model:': 'モデル別:', + 'By auth type:': '認証タイプ別:', + 'By model/auth type:': 'モデル/認証タイプ別:', + '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 使用量のエクスポート先はプロジェクト作業ディレクトリ内である必要があります。', + 'Token usage exported to {{format}}: {{path}}': + 'token 使用量を {{format}} にエクスポートしました: {{path}}', + 'Failed to export token usage stats: {{error}}': + 'token 使用統計のエクスポートに失敗しました: {{error}}', + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', '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..da17c7c8de1 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -21,6 +21,24 @@ export default { 'Auto mode': 'Modo auto', 'plan mode': 'modo planejamento', 'auto-accept edits': 'aceitar edições automaticamente', + 'auto mode (classifier-evaluated)': + 'modo automático (classificador avaliado)', + 'Auto mode enabled.': 'Modo automático ativado.', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + 'Um classificador LLM avalia cada chamada de ferramenta e aprova automaticamente ações seguras,', + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + 'bloqueia as arriscadas. A maioria das operações somente leitura e edições no diretório atual ignoram o', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + 'classificador por velocidade. Para sair: Shift+Tab ou /approval-mode default.', + '(This notice will not appear again.)': + '(Este aviso não aparecerá novamente.)', + 'Auto mode temporarily disabled these allow rules': + 'O modo automático desativou temporariamente estas regras de permissão', + '(they would bypass the classifier):': '(elas contornariam o classificador):', + '(from user settings)': '(das configurações do usuário)', + '(session)': '(sessão)', + 'These will be restored when leaving auto mode.': + 'Elas serão restauradas ao sair do modo automático.', 'Accepting edits': 'Aceitando edições', '(shift + tab to cycle)': '(Shift + Tab para alternar)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': @@ -443,12 +461,50 @@ 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]', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + 'verificar estatísticas da sessão. Uso: /stats [model|tools|daily|monthly|export]', '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: {{tokens}}': 'Cache: {{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:', + '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.', + '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}}', + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', '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..a8563f930f3 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -27,6 +27,24 @@ export default { 'Auto mode': 'Автоматический режим', 'plan mode': 'Режим планирования', 'auto-accept edits': 'Режим принятия правок', + 'auto mode (classifier-evaluated)': + 'Автоматический режим (классификатор выполнен)', + 'Auto mode enabled.': 'Автоматический режим включен.', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + 'LLM-классификатор оценивает каждый вызов инструмента и автоматически одобряет безопасные действия,', + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + 'блокируя рискованные. Большинство операций только для чтения и правок в рабочем каталоге пропускают', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + 'классификатор для скорости. Выход: Shift+Tab или /approval-mode default.', + '(This notice will not appear again.)': + '(Это уведомление больше не появится.)', + 'Auto mode temporarily disabled these allow rules': + 'Автоматический режим временно отключил эти разрешающие правила', + '(they would bypass the classifier):': '(они обходили бы классификатор):', + '(from user settings)': '(из пользовательских настроек)', + '(session)': '(сеанс)', + 'These will be restored when leaving auto mode.': + 'Они будут восстановлены при выходе из автоматического режима.', 'Accepting edits': 'Принятие правок', '(shift + tab to cycle)': '(Shift + Tab для переключения)', '(tab to cycle)': '(Tab для переключения)', @@ -438,12 +456,52 @@ export default { 'Auto Edit': 'Авторедактирование', YOLO: 'YOLO', 'toggle vim mode on/off': 'Включение/выключение режима vim', - 'check session stats. Usage: /stats [model|tools]': - 'Просмотр статистики сессии. Использование: /stats [model|tools]', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + 'Просмотр статистики сессии. Использование: /stats [model|tools|daily|monthly|export]', '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: {{tokens}}': 'Кэш: {{tokens}}', + 'Thoughts: {{tokens}}': 'Рассуждения: {{tokens}}', + 'By model:': 'По модели:', + 'By auth type:': 'По типу аутентификации:', + 'By model/auth type:': 'По модели/типу аутентификации:', + '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.': + 'Путь экспорта использования токенов должен находиться внутри рабочего каталога проекта.', + 'Token usage exported to {{format}}: {{path}}': + 'Использование токенов экспортировано в {{format}}: {{path}}', + 'Failed to export token usage stats: {{error}}': + 'Не удалось экспортировать статистику использования токенов: {{error}}', + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': + 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', '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..01ac87e68b9 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -23,6 +23,22 @@ export default { 'Auto mode': 'Auto 模式', 'plan mode': '規劃模式', 'auto-accept edits': '自動接受編輯', + 'auto mode (classifier-evaluated)': '自動模式(分類器已評估)', + 'Auto mode enabled.': '自動模式已啟用。', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + 'LLM 分類器會評估每次工具呼叫,並自動核准安全操作,', + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + '阻止有風險的操作。多數唯讀操作和目前目錄內編輯會跳過', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + '分類器以提升速度。退出方式:Shift+Tab 或 /approval-mode default。', + '(This notice will not appear again.)': '(此提示不會再次顯示。)', + 'Auto mode temporarily disabled these allow rules': + '自動模式已暫時停用以下允許規則', + '(they would bypass the classifier):': '(它們會繞過分類器):', + '(from user settings)': '(來自使用者設定)', + '(session)': '(會話)', + 'These will be restored when leaving auto mode.': + '退出自動模式時將復原這些規則。', 'Accepting edits': '接受編輯', '(shift + tab to cycle)': '(Shift + Tab 切換)', '(tab to cycle)': '(按 Tab 切換)', @@ -454,8 +470,52 @@ export default { 'Auto Edit': '自動編輯', YOLO: 'YOLO', 'toggle vim mode on/off': '切換 vim 模式開關', - 'Show model-specific usage statistics.': '顯示模型相關的使用統計信息', - 'Show tool-specific usage statistics.': '顯示工具相關的使用統計信息', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + '檢查會話統計資訊。用法:/stats [model|tools|daily|monthly|export]', + '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..f74242e18f4 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -25,6 +25,22 @@ export default { 'Auto mode': 'Auto 模式', 'plan mode': '规划模式', 'auto-accept edits': '自动接受编辑', + 'auto mode (classifier-evaluated)': '自动模式(分类器已评估)', + 'Auto mode enabled.': '自动模式已启用。', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,': + 'LLM 分类器会评估每次工具调用,并自动批准安全操作,', + 'blocks risky ones. Most read-only operations and in-cwd edits skip the': + '阻止有风险的操作。多数只读操作和当前目录内编辑会跳过', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': + '分类器以提升速度。退出方式:Shift+Tab 或 /approval-mode default。', + '(This notice will not appear again.)': '(此提示不会再次显示。)', + 'Auto mode temporarily disabled these allow rules': + '自动模式已临时禁用以下允许规则', + '(they would bypass the classifier):': '(它们会绕过分类器):', + '(from user settings)': '(来自用户设置)', + '(session)': '(会话)', + 'These will be restored when leaving auto mode.': + '退出自动模式时将恢复这些规则。', 'Accepting edits': '接受编辑', '(shift + tab to cycle)': '(Shift + Tab 切换)', '(tab to cycle)': '(按 Tab 切换)', @@ -495,9 +511,53 @@ export default { 'Auto Edit': '自动编辑', YOLO: 'YOLO', 'toggle vim mode on/off': '切换 vim 模式开关', + 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': + '检查会话统计信息。用法:/stats [model|tools|daily|monthly|export]', '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..43850114827 100644 --- a/packages/cli/src/i18n/mustTranslateKeys.ts +++ b/packages/cli/src/i18n/mustTranslateKeys.ts @@ -82,6 +82,17 @@ export const MUST_TRANSLATE_KEYS = [ 'No extensions found.', 'Toggle this help display', 'Toggle shell mode', + 'auto mode (classifier-evaluated)', + 'Auto mode enabled.', + 'An LLM classifier evaluates each tool call and auto-approves safe actions,', + 'blocks risky ones. Most read-only operations and in-cwd edits skip the', + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.', + '(This notice will not appear again.)', + 'Auto mode temporarily disabled these allow rules', + '(they would bypass the classifier):', + '(from user settings)', + '(session)', + 'These will be restored when leaving auto mode.', 'Open command menu', 'Add file context', 'Accept suggestion / Autocomplete', 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/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx new file mode 100644 index 00000000000..a2aeafed8ea --- /dev/null +++ b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { setLanguageAsync } from '../../i18n/index.js'; +import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; + +describe('', () => { + beforeEach(async () => { + await setLanguageAsync('en'); + }); + + afterAll(async () => { + await setLanguageAsync('en'); + }); + + it('localizes AUTO mode classifier status in Chinese', async () => { + await setLanguageAsync('zh'); + + const { lastFrame } = render( + , + ); + + const output = lastFrame() ?? ''; + expect(output).toContain('自动模式(分类器已评估)'); + expect(output).not.toContain('auto mode'); + expect(output).not.toContain('classifier-evaluated'); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 30d34677179..98a95316295 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -10,6 +10,7 @@ import { expect, vi, beforeEach, + afterEach, type MockedFunction, type Mock, } from 'vitest'; @@ -21,6 +22,7 @@ import type { Config as ActualConfigType } from '@qwen-code/qwen-code-core'; import type { Key } from './useKeypress.js'; import { useKeypress } from './useKeypress.js'; import { MessageType } from '../types.js'; +import { setLanguageAsync } from '../../i18n/index.js'; vi.mock('./useKeypress.js'); @@ -122,6 +124,10 @@ describe('useAutoAcceptIndicator', () => { mockConfigInstance = new (Config as any)() as MockConfigInstanceShape; }); + afterEach(async () => { + await setLanguageAsync('en'); + }); + it('should initialize with ApprovalMode.AUTO_EDIT if config.getApprovalMode returns ApprovalMode.AUTO_EDIT', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); const { result } = renderHook(() => @@ -569,4 +575,49 @@ describe('useAutoAcceptIndicator', () => { value: originalPlatform, }); }); + + it('localizes AUTO entry notices and stripped allow-rule sources', async () => { + await setLanguageAsync('zh'); + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); + const mockAddItem = vi.fn(); + const mockSettings = { + merged: { + ui: { + autoModeAcknowledged: true, + }, + }, + }; + const mockPermissionManager = { + getStrippedDangerousRules: vi.fn(() => ({ + persistent: [{ raw: 'Bash(npm run *)' }], + session: [{ raw: 'Bash(node *)' }], + })), + }; + const configWithPermissionManager = { + ...mockConfigInstance, + getPermissionManager: vi.fn(() => mockPermissionManager), + }; + + renderHook(() => + useAutoAcceptIndicator({ + config: configWithPermissionManager as unknown as ActualConfigType, + settings: mockSettings as never, + addItem: mockAddItem, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); + + const noticeText = mockAddItem.mock.calls.at(-1)?.[0].text as string; + expect(noticeText).toContain('自动模式已临时禁用以下允许规则'); + expect(noticeText).toContain('(它们会绕过分类器):'); + expect(noticeText).toContain('Bash(npm run *) (来自用户设置)'); + expect(noticeText).toContain('Bash(node *) (会话)'); + expect(noticeText).toContain('退出自动模式时将恢复这些规则。'); + expect(noticeText).not.toContain('Auto mode temporarily disabled'); + expect(noticeText).not.toContain('from user settings'); + expect(noticeText).not.toContain('These will be restored'); + }); }); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts index 00596888b9f..e7f85a8d710 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts @@ -14,13 +14,22 @@ import { useKeypress } from './useKeypress.js'; import type { HistoryItemWithoutId } from '../types.js'; import { MessageType } from '../types.js'; import { type LoadedSettings, SettingScope } from '../../config/settings.js'; - -const AUTO_MODE_FIRST_TIME_MESSAGE = - '✨ Auto mode enabled.\n' + - ' An LLM classifier evaluates each tool call and auto-approves safe actions,\n' + - ' blocks risky ones. Most read-only operations and in-cwd edits skip the\n' + - ' classifier for speed. To exit: Shift+Tab or /approval-mode default.\n' + - ' (This notice will not appear again.)'; +import { t } from '../../i18n/index.js'; + +const getAutoModeFirstTimeMessage = (): string => + [ + `✨ ${t('Auto mode enabled.')}`, + ` ${t( + 'An LLM classifier evaluates each tool call and auto-approves safe actions,', + )}`, + ` ${t( + 'blocks risky ones. Most read-only operations and in-cwd edits skip the', + )}`, + ` ${t( + 'classifier for speed. To exit: Shift+Tab or /approval-mode default.', + )}`, + ` ${t('(This notice will not appear again.)')}`, + ].join('\n'); export interface UseAutoAcceptIndicatorArgs { config: Config; @@ -147,7 +156,7 @@ export function emitAutoModeEntryNotices(opts: { const acknowledged = settings?.merged.ui?.autoModeAcknowledged === true; if (!acknowledged) { addItem( - { type: MessageType.INFO, text: AUTO_MODE_FIRST_TIME_MESSAGE }, + { type: MessageType.INFO, text: getAutoModeFirstTimeMessage() }, now, ); if (settings) { @@ -168,11 +177,13 @@ export function emitAutoModeEntryNotices(opts: { (stripped.persistent.length > 0 || stripped.session.length > 0) ) { const lines = [ - 'ℹ️ Auto mode temporarily disabled these allow rules', - ' (they would bypass the classifier):', - ...stripped.persistent.map((r) => ` - ${r.raw} (from user settings)`), - ...stripped.session.map((r) => ` - ${r.raw} (session)`), - ' These will be restored when leaving auto mode.', + `ℹ️ ${t('Auto mode temporarily disabled these allow rules')}`, + ` ${t('(they would bypass the classifier):')}`, + ...stripped.persistent.map( + (r) => ` - ${r.raw} ${t('(from user settings)')}`, + ), + ...stripped.session.map((r) => ` - ${r.raw} ${t('(session)')}`), + ` ${t('These will be restored when leaving auto mode.')}`, ]; addItem({ type: MessageType.INFO, text: lines.join('\n') }, now + 1); } 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..ba2d76829d8 --- /dev/null +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -0,0 +1,405 @@ +/** + * @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 { + apiResponseEventToTokenUsageRecord, + exportTokenUsageSummary, + formatTokenUsageSummaryAsCsv, + getTokenUsageFilePath, + queryTokenUsage, + recordTokenUsageFromApiResponse, +} from './tokenUsageService.js'; + +describe('tokenUsageService', () => { + let tempDir: string; + let originalRuntimeDir: string | undefined; + + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-25T10:00:00.000Z')); + originalRuntimeDir = process.env['QWEN_RUNTIME_DIR']; + tempDir = await mkdtemp(path.join(tmpdir(), 'qwen-token-usage-')); + process.env['QWEN_RUNTIME_DIR'] = tempDir; + }); + + afterEach(async () => { + vi.useRealTimers(); + 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 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('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('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('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('tolerates malformed JSONL lines while querying', 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}', + '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); + }); + + it('exports summaries as JSON and escaped CSV', async () => { + const config = makeFakeConfig({ + sessionId: 'session-1', + targetDir: path.join(tempDir, 'project'), + }); + await recordTokenUsageFromApiResponse( + config, + createEvent( + 'model,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 }, + coordination: { issues: ['#4479', '#4252', '#4182'] }, + }); + + const csv = formatTokenUsageSummaryAsCsv( + await queryTokenUsage({ period: 'day', value: '2026-05-25' }), + ); + expect(csv).toContain( + 'day,2026-05-25,model,"model,quoted","model,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('validates period values', async () => { + await expect( + queryTokenUsage({ period: 'day', value: '2026-05' }), + ).rejects.toThrow('Expected YYYY-MM-DD'); + await expect( + queryTokenUsage({ period: 'month', value: '2026-05-25' }), + ).rejects.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..752e93c8f67 --- /dev/null +++ b/packages/core/src/services/tokenUsageService.ts @@ -0,0 +1,442 @@ +/** + * @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; + localDate: string; + 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[]; + coordination: { + issues: string[]; + notes: string[]; + }; +} + +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 { + return /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +function isValidMonth(value: string): boolean { + return /^\d{4}-\d{2}$/.test(value); +} + +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 calculateTotalTokens(event: ApiResponseEvent): number { + const total = toNonNegativeInteger(event.total_token_count); + if (total > 0) { + return total; + } + return ( + toNonNegativeInteger(event.input_token_count) + + 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: toNonNegativeInteger(event.input_token_count), + 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 ( + 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); + return records.filter(isTokenUsageRecord); +} + +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()), + coordination: { + issues: ['#4479', '#4252', '#4182'], + notes: [ + 'Token usage is exposed under /stats to share the statistics command surface.', + 'apiDurationMs is API response duration only; generation timing, TTFT, and TPS remain out of scope for #4252.', + 'Usage records are content-free aggregate counters and dimensions for #4182 compatibility.', + ], + }, + }; +} + +export async function recordTokenUsageFromApiResponse( + config: Config, + event: ApiResponseEvent, +): Promise { + const record = apiResponseEventToTokenUsageRecord(config, event); + await jsonl.writeLine(getTokenUsageFilePath(record.localMonth), record); +} + +export function recordTokenUsageFromApiResponseBestEffort( + config: Config, + event: ApiResponseEvent, +): void { + void recordTokenUsageFromApiResponse(config, event).catch( + (error: unknown) => { + debugLogger.warn('Failed to record token usage:', 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); + if (/[",\n\r]/.test(stringValue)) { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return stringValue; +} + +function groupRows( + groupType: string, + groups: TokenUsageGroupSummary[], +): string[][] { + return groups.map((group) => [ + 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 = [ + [ + '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('model', summary.byModel), + ...groupRows('auth_type', summary.byAuthType), + ...groupRows('model_auth_type', summary.byModelAndAuthType), + ...groupRows('source', summary.bySource), + ]; + + return [ + header.join(','), + ...rows.map((row) => + [summary.period, summary.value, ...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); From 405e8d85188800d56f3c20f42f2fd9f6b8272864 Mon Sep 17 00:00:00 2001 From: sheny Date: Wed, 27 May 2026 14:56:25 +0800 Subject: [PATCH 02/13] fix: Address token usage review feedback Tighten persisted token usage so internal prompt traffic and disabled usage statistics do not write history, while surfacing non-ENOENT write failures outside debug logs. Complete the reviewer-requested i18n coverage and regression tests around auto mode notices and best-effort writes. Constraint: Follow-up to wenshao review comments on PR #4564. Rejected: Keeping token usage recording outside the internal-prompt gate | It would inflate daily and monthly stats with background prompts. Confidence: high Scope-risk: narrow Directive: Keep /stats token usage scoped to user-visible API responses unless future requirements explicitly include background traffic. Tested: npx vitest run src/services/tokenUsageService.test.ts src/telemetry/loggers.test.ts; npx vitest run src/ui/hooks/useAutoAcceptIndicator.test.ts src/ui/commands/statsCommand.test.ts; npm run typecheck; npm run lint --workspace=packages/core; npm run lint --workspace=packages/cli; npm run check-i18n --workspace=packages/cli; npm run build; git diff --check Not-tested: Full repository test suite --- packages/cli/src/i18n/locales/ca.js | 2 +- packages/cli/src/i18n/locales/de.js | 2 +- packages/cli/src/i18n/locales/fr.js | 2 +- packages/cli/src/i18n/locales/ja.js | 2 +- packages/cli/src/i18n/locales/pt.js | 2 +- packages/cli/src/i18n/locales/ru.js | 2 +- packages/cli/src/i18n/mustTranslateKeys.ts | 10 +++ .../ui/hooks/useAutoAcceptIndicator.test.ts | 80 +++++++++++++++++++ .../src/services/tokenUsageService.test.ts | 72 +++++++++++++++++ .../core/src/services/tokenUsageService.ts | 8 ++ packages/core/src/telemetry/loggers.test.ts | 49 ++++++++++++ 11 files changed, 225 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index f3236f1cb0e..b66b7d3293a 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -549,7 +549,7 @@ export default { 'Failed to export token usage stats: {{error}}': "No s'han pogut exportar les estadístiques d'ús de tokens: {{error}}", 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', + "Nota: l'ús de tokens només mostra la durada de resposta de l'API; 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 56a52ae67bf..9271c829dfd 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -481,7 +481,7 @@ export default { 'Failed to export token usage stats: {{error}}': 'Token-Nutzungsstatistiken konnten nicht exportiert werden: {{error}}', 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', + 'Hinweis: Die Token-Nutzung zeigt nur die API-Antwortdauer; 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/fr.js b/packages/cli/src/i18n/locales/fr.js index 5da26f835c1..164d8c588c2 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -557,7 +557,7 @@ export default { 'Failed to export token usage stats: {{error}}': "Échec de l'export des statistiques d'utilisation des tokens : {{error}}", 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', + "Remarque : l'utilisation des tokens affiche uniquement la durée de réponse de l'API ; 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 364aab264d4..652d96a2380 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -415,7 +415,7 @@ export default { 'Failed to export token usage stats: {{error}}': 'token 使用統計のエクスポートに失敗しました: {{error}}', 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', + '注: token 使用量は API 応答時間のみを示します。生成時間(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 da17c7c8de1..3fcc456569e 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -504,7 +504,7 @@ export default { 'Failed to export token usage stats: {{error}}': 'Falha ao exportar estatísticas de uso de tokens: {{error}}', 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', + 'Observação: o uso de tokens mostra apenas a duração da resposta da API; 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 a8563f930f3..366ecc8e8a4 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -501,7 +501,7 @@ export default { 'Failed to export token usage stats: {{error}}': 'Не удалось экспортировать статистику использования токенов: {{error}}', 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.', + 'Примечание: использование токенов показывает только длительность ответа API; время генерации (TTFT/TPS) относится к метрикам генерации.', 'exit the cli': 'Выход из CLI', 'Manage workspace directories': 'Управление директориями рабочего пространства', diff --git a/packages/cli/src/i18n/mustTranslateKeys.ts b/packages/cli/src/i18n/mustTranslateKeys.ts index 43850114827..f8aefdee0c2 100644 --- a/packages/cli/src/i18n/mustTranslateKeys.ts +++ b/packages/cli/src/i18n/mustTranslateKeys.ts @@ -106,6 +106,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/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 98a95316295..6ac7ef67b97 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -22,6 +22,7 @@ import type { Config as ActualConfigType } from '@qwen-code/qwen-code-core'; import type { Key } from './useKeypress.js'; import { useKeypress } from './useKeypress.js'; import { MessageType } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; import { setLanguageAsync } from '../../i18n/index.js'; vi.mock('./useKeypress.js'); @@ -140,6 +141,43 @@ describe('useAutoAcceptIndicator', () => { expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1); }); + it('should show AUTO entry notices on mount when initially in AUTO mode', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO); + const mockAddItem = vi.fn(); + const mockSetValue = vi.fn(); + const mockSettings = { + merged: { + ui: { + autoModeAcknowledged: false, + }, + }, + setValue: mockSetValue, + }; + + const { result } = renderHook(() => + useAutoAcceptIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + settings: mockSettings as never, + addItem: mockAddItem, + }), + ); + + expect(result.current).toBe(ApprovalMode.AUTO); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Auto mode enabled.'), + }), + expect.any(Number), + ); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'ui.autoModeAcknowledged', + true, + ); + expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1); + }); + it('should initialize with ApprovalMode.DEFAULT if config.getApprovalMode returns ApprovalMode.DEFAULT', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); const { result } = renderHook(() => @@ -620,4 +658,46 @@ describe('useAutoAcceptIndicator', () => { expect(noticeText).not.toContain('from user settings'); expect(noticeText).not.toContain('These will be restored'); }); + + it('persists the first-time AUTO notice when cycling into AUTO mode', () => { + mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); + const mockAddItem = vi.fn(); + const mockSetValue = vi.fn(); + const mockSettings = { + merged: { + ui: { + autoModeAcknowledged: false, + }, + }, + setValue: mockSetValue, + }; + + renderHook(() => + useAutoAcceptIndicator({ + config: mockConfigInstance as unknown as ActualConfigType, + settings: mockSettings as never, + addItem: mockAddItem, + }), + ); + + act(() => { + capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); + }); + + expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.AUTO, + ); + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Auto mode enabled.'), + }), + expect.any(Number), + ); + expect(mockSetValue).toHaveBeenCalledWith( + SettingScope.User, + 'ui.autoModeAcknowledged', + true, + ); + }); }); diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index ba2d76829d8..a21108fb4f9 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -13,6 +13,7 @@ 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 * as jsonl from '../utils/jsonl-utils.js'; import { apiResponseEventToTokenUsageRecord, exportTokenUsageSummary, @@ -20,6 +21,7 @@ import { getTokenUsageFilePath, queryTokenUsage, recordTokenUsageFromApiResponse, + recordTokenUsageFromApiResponseBestEffort, } from './tokenUsageService.js'; describe('tokenUsageService', () => { @@ -196,6 +198,76 @@ describe('tokenUsageService', () => { ]); }); + 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); + const unhandled: unknown[] = []; + const handler = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', handler); + + try { + expect(() => + recordTokenUsageFromApiResponseBestEffort(config, event), + ).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(unhandled).toHaveLength(0); + expect(stderrSpy).toHaveBeenCalledWith( + '[token-usage] Write failed (ENOSPC):', + 'disk full', + ); + } finally { + process.off('unhandledRejection', handler); + 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); + await Promise.resolve(); + await Promise.resolve(); + + 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', diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index 752e93c8f67..7988c338cfa 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -333,6 +333,14 @@ export function recordTokenUsageFromApiResponseBestEffort( void recordTokenUsageFromApiResponse(config, event).catch( (error: unknown) => { debugLogger.warn('Failed to record token usage:', error); + const code = (error as NodeJS.ErrnoException).code; + if (code && code !== 'ENOENT') { + // eslint-disable-next-line no-console -- surface persistent local write failures outside debug mode + console.error( + `[token-usage] Write failed (${code}):`, + error instanceof Error ? error.message : String(error), + ); + } }, ); } diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 387a588fc1b..5590a72616f 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -426,6 +426,55 @@ describe('loggers', () => { tokenUsageService.recordTokenUsageFromApiResponseBestEffort, ).not.toHaveBeenCalled(); }); + + 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', () => { From 1c2c570a4b764598b0ecbbe3d592ceb5bb11fbd5 Mon Sep 17 00:00:00 2001 From: sheny Date: Wed, 27 May 2026 18:38:38 +0800 Subject: [PATCH 03/13] fix(stats): satisfy token usage review contract Constraint: wenshao review required consistent token stats, exports, i18n, and best-effort logging behavior. Rejected: Change cached-token labeling | keeping cached tokens included in input preserves the accepted /stats display contract. Confidence: high Scope-risk: narrow Directive: Keep cached tokens included in input whenever cached-only metadata is used in total fallback. Tested: cd packages/core && npx vitest run src/services/tokenUsageService.test.ts src/telemetry/loggers.test.ts Tested: cd packages/cli && npx vitest run src/ui/commands/statsCommand.test.ts src/i18n/mustTranslateKeys.test.ts Tested: npm run check-i18n --workspace=packages/cli; npm run typecheck; git diff --check Not-tested: full integration suite --- packages/cli/src/i18n/locales/ca.js | 9 +- packages/cli/src/i18n/locales/de.js | 10 +- packages/cli/src/i18n/locales/fr.js | 9 +- packages/cli/src/i18n/locales/ja.js | 9 +- packages/cli/src/i18n/locales/pt.js | 9 +- packages/cli/src/i18n/locales/ru.js | 8 +- packages/cli/src/ui/commands/statsCommand.ts | 3 + .../src/services/tokenUsageService.test.ts | 111 +++++++++++++++--- .../core/src/services/tokenUsageService.ts | 102 ++++++++++------ packages/core/src/telemetry/loggers.test.ts | 46 ++++++++ 10 files changed, 249 insertions(+), 67 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index b66b7d3293a..868cdd27518 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -528,11 +528,13 @@ export default { 'Breakdown:': 'Desglossament:', 'Input: {{tokens}}': 'Entrada: {{tokens}}', 'Output: {{tokens}}': 'Sortida: {{tokens}}', - 'Cached: {{tokens}}': 'Memòria cau: {{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.': @@ -548,8 +550,9 @@ export default { 'Ú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}}", - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - "Nota: l'ús de tokens només mostra la durada de resposta de l'API; el temps de generació (TTFT/TPS) pertany a les mètriques de generació.", + '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 9271c829dfd..934fe656f4a 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -460,11 +460,13 @@ export default { 'Breakdown:': 'Aufschlüsselung:', 'Input: {{tokens}}': 'Eingabe: {{tokens}}', 'Output: {{tokens}}': 'Ausgabe: {{tokens}}', - 'Cached: {{tokens}}': 'Cache: {{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.': @@ -480,8 +482,10 @@ export default { 'Token-Nutzung nach {{format}} exportiert: {{path}}', 'Failed to export token usage stats: {{error}}': 'Token-Nutzungsstatistiken konnten nicht exportiert werden: {{error}}', - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Hinweis: Die Token-Nutzung zeigt nur die API-Antwortdauer; Generierungszeiten (TTFT/TPS) gehören zu den Generierungsmetriken.', + '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/fr.js b/packages/cli/src/i18n/locales/fr.js index 164d8c588c2..daf959b4141 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -536,11 +536,13 @@ export default { 'Breakdown:': 'Détail :', 'Input: {{tokens}}': 'Entrée : {{tokens}}', 'Output: {{tokens}}': 'Sortie : {{tokens}}', - 'Cached: {{tokens}}': 'Cache : {{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.': @@ -556,8 +558,9 @@ export default { '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}}", - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - "Remarque : l'utilisation des tokens affiche uniquement la durée de réponse de l'API ; les temps de génération (TTFT/TPS) relèvent des métriques de génération.", + '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 652d96a2380..d1ac052a9c2 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -394,11 +394,13 @@ export default { 'Breakdown:': '内訳:', 'Input: {{tokens}}': '入力: {{tokens}}', 'Output: {{tokens}}': '出力: {{tokens}}', - 'Cached: {{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.': @@ -414,8 +416,9 @@ export default { 'token 使用量を {{format}} にエクスポートしました: {{path}}', 'Failed to export token usage stats: {{error}}': 'token 使用統計のエクスポートに失敗しました: {{error}}', - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - '注: token 使用量は API 応答時間のみを示します。生成時間(TTFT/TPS)は生成メトリクスに属します。', + '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 3fcc456569e..e9aae5472da 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -483,11 +483,13 @@ export default { 'Breakdown:': 'Detalhamento:', 'Input: {{tokens}}': 'Entrada: {{tokens}}', 'Output: {{tokens}}': 'Saída: {{tokens}}', - 'Cached: {{tokens}}': 'Cache: {{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.': @@ -503,8 +505,9 @@ export default { 'Uso de tokens exportado para {{format}}: {{path}}', 'Failed to export token usage stats: {{error}}': 'Falha ao exportar estatísticas de uso de tokens: {{error}}', - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Observação: o uso de tokens mostra apenas a duração da resposta da API; o tempo de geração (TTFT/TPS) pertence às métricas de geração.', + '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 366ecc8e8a4..32eb84ad8e8 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -480,11 +480,12 @@ export default { 'Breakdown:': 'Разбивка:', 'Input: {{tokens}}': 'Ввод: {{tokens}}', 'Output: {{tokens}}': 'Вывод: {{tokens}}', - 'Cached: {{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.': @@ -500,8 +501,9 @@ export default { 'Использование токенов экспортировано в {{format}}: {{path}}', 'Failed to export token usage stats: {{error}}': 'Не удалось экспортировать статистику использования токенов: {{error}}', - 'Note: token usage shows API response duration only; generation timing (TTFT/TPS) belongs to generation metrics.': - 'Примечание: использование токенов показывает только длительность ответа API; время генерации (TTFT/TPS) относится к метрикам генерации.', + '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/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index f1cb4325bb0..9c3f89b1872 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -193,6 +193,9 @@ function tokenizeArgs(args: string): string[] { current += char; } + if (quote !== undefined) { + throw new Error(t('Unclosed quote in arguments.')); + } if (quote !== undefined) { throw new Error(t('Unclosed quote in arguments.')); } diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index a21108fb4f9..de555fa4034 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import fs from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { GenerateContentResponseUsageMetadata } from '@google/genai'; @@ -198,7 +199,7 @@ describe('tokenUsageService', () => { ]); }); - it('swallows best-effort write errors and surfaces non-ENOENT failures', async () => { + it('swallows best-effort write errors and surfaces non-ENOENT failures', () => { const config = makeFakeConfig({ sessionId: 'session-1', targetDir: path.join(tempDir, 'project'), @@ -209,35 +210,32 @@ describe('tokenUsageService', () => { totalTokenCount: 3, }); const error = Object.assign(new Error('disk full'), { code: 'ENOSPC' }); - const writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); + const writeSpy = vi + .spyOn(jsonl, 'writeLineSync') + .mockImplementationOnce(() => { + throw error; + }); const stderrSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); - const unhandled: unknown[] = []; - const handler = (reason: unknown) => unhandled.push(reason); - process.on('unhandledRejection', handler); try { expect(() => recordTokenUsageFromApiResponseBestEffort(config, event), ).not.toThrow(); - await Promise.resolve(); - await Promise.resolve(); expect(writeSpy).toHaveBeenCalledTimes(1); - expect(unhandled).toHaveLength(0); expect(stderrSpy).toHaveBeenCalledWith( '[token-usage] Write failed (ENOSPC):', 'disk full', ); } finally { - process.off('unhandledRejection', handler); writeSpy.mockRestore(); stderrSpy.mockRestore(); } }); - it('does not surface best-effort ENOENT write failures to stderr', async () => { + it('does not surface best-effort ENOENT write failures to stderr', () => { const config = makeFakeConfig({ sessionId: 'session-1', targetDir: path.join(tempDir, 'project'), @@ -250,15 +248,17 @@ describe('tokenUsageService', () => { const error = Object.assign(new Error('missing directory'), { code: 'ENOENT', }); - const writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); + const writeSpy = vi + .spyOn(jsonl, 'writeLineSync') + .mockImplementationOnce(() => { + throw error; + }); const stderrSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); try { recordTokenUsageFromApiResponseBestEffort(config, event); - await Promise.resolve(); - await Promise.resolve(); expect(writeSpy).toHaveBeenCalledTimes(1); expect(stderrSpy).not.toHaveBeenCalled(); @@ -383,6 +383,32 @@ describe('tokenUsageService', () => { 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', @@ -422,6 +448,27 @@ describe('tokenUsageService', () => { expect(summary.totals.requests).toBe(1); }); + 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', @@ -430,7 +477,7 @@ describe('tokenUsageService', () => { await recordTokenUsageFromApiResponse( config, createEvent( - 'model,quoted', + '=cmd|quoted', 'prompt-1', { promptTokenCount: 1, @@ -452,26 +499,58 @@ describe('tokenUsageService', () => { period: 'day', value: '2026-05-25', totals: { totalTokens: 3 }, - coordination: { issues: ['#4479', '#4252', '#4182'] }, }); + 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,model,"model,quoted","model,quoted",,,1,1,2,0,0,3,100', + "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('persists best-effort records synchronously', () => { + 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 = fs.readFileSync( + 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 index 7988c338cfa..4709ddffacb 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -71,10 +71,6 @@ export interface TokenUsageSummary { byAuthType: TokenUsageGroupSummary[]; byModelAndAuthType: TokenUsageGroupSummary[]; bySource: TokenUsageGroupSummary[]; - coordination: { - issues: string[]; - notes: string[]; - }; } export interface TokenUsageQuery { @@ -127,11 +123,44 @@ function currentPeriodValue(period: TokenUsagePeriod): string { } function isValidDay(value: string): boolean { - return /^\d{4}-\d{2}-\d{2}$/.test(value); + 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 { - return /^\d{4}-\d{2}$/.test(value); + 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( @@ -169,13 +198,21 @@ function toNonNegativeInteger(value: number | undefined): number { return Math.trunc(value); } +function calculateInputTokens(event: ApiResponseEvent): number { + const inputTokens = toNonNegativeInteger(event.input_token_count); + if (inputTokens > 0) { + return inputTokens; + } + 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 ( - toNonNegativeInteger(event.input_token_count) + + calculateInputTokens(event) + toNonNegativeInteger(event.output_token_count) + toNonNegativeInteger(event.thoughts_token_count) ); @@ -200,7 +237,7 @@ export function apiResponseEventToTokenUsageRecord( model: event.model || 'unknown', authType: event.auth_type || UNKNOWN_AUTH_TYPE, source: event.subagent_name || MAIN_SOURCE, - inputTokens: toNonNegativeInteger(event.input_token_count), + inputTokens: calculateInputTokens(event), outputTokens: toNonNegativeInteger(event.output_token_count), cachedTokens: toNonNegativeInteger(event.cached_content_token_count), thoughtsTokens: toNonNegativeInteger(event.thoughts_token_count), @@ -215,7 +252,10 @@ function isTokenUsageRecord(value: unknown): value is TokenUsageRecord { } const record = value as Partial; return ( - record.schemaVersion === SCHEMA_VERSION && + 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' && @@ -307,14 +347,6 @@ function summarizeRecords( byAuthType: sortGroups(byAuthType.values()), byModelAndAuthType: sortGroups(byModelAndAuthType.values()), bySource: sortGroups(bySource.values()), - coordination: { - issues: ['#4479', '#4252', '#4182'], - notes: [ - 'Token usage is exposed under /stats to share the statistics command surface.', - 'apiDurationMs is API response duration only; generation timing, TTFT, and TPS remain out of scope for #4252.', - 'Usage records are content-free aggregate counters and dimensions for #4182 compatibility.', - ], - }, }; } @@ -330,19 +362,20 @@ export function recordTokenUsageFromApiResponseBestEffort( config: Config, event: ApiResponseEvent, ): void { - void recordTokenUsageFromApiResponse(config, event).catch( - (error: unknown) => { - debugLogger.warn('Failed to record token usage:', error); - const code = (error as NodeJS.ErrnoException).code; - if (code && code !== 'ENOENT') { - // eslint-disable-next-line no-console -- surface persistent local write failures outside debug mode - console.error( - `[token-usage] Write failed (${code}):`, - error instanceof Error ? error.message : String(error), - ); - } - }, - ); + try { + const record = apiResponseEventToTokenUsageRecord(config, event); + jsonl.writeLineSync(getTokenUsageFilePath(record.localMonth), record); + } catch (error) { + debugLogger.warn('Failed to record token usage:', error); + const code = (error as NodeJS.ErrnoException).code; + if (code && code !== 'ENOENT') { + // eslint-disable-next-line no-console -- surface persistent local write failures outside debug mode + console.error( + `[token-usage] Write failed (${code}):`, + error instanceof Error ? error.message : String(error), + ); + } + } } export async function queryTokenUsage( @@ -360,10 +393,13 @@ export async function queryTokenUsage( function csvEscape(value: string | number | undefined): string { const stringValue = value === undefined ? '' : String(value); - if (/[",\n\r]/.test(stringValue)) { - return `"${stringValue.replace(/"/g, '""')}"`; + const sanitized = /^[=+\-@\t\r\n]/.test(stringValue) + ? `'${stringValue}` + : stringValue; + if (/[",\n\r]/.test(sanitized)) { + return `"${sanitized.replace(/"/g, '""')}"`; } - return stringValue; + return sanitized; } function groupRows( diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 5590a72616f..628e6a84e48 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -95,6 +95,22 @@ import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import * as uiTelemetry from './uiTelemetry.js'; import { makeFakeConfig } from '../test-utils/config.js'; +const mockDebugLogger = vi.hoisted(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../utils/debugLogger.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createDebugLogger: vi.fn(() => mockDebugLogger), + }; +}); + describe('loggers', () => { const mockLogger = { emit: vi.fn(), @@ -110,6 +126,10 @@ describe('loggers', () => { vi.spyOn(uiTelemetry.uiTelemetryService, 'addEvent').mockImplementation( mockUiEvent.addEvent, ); + mockDebugLogger.debug.mockClear(); + mockDebugLogger.info.mockClear(); + mockDebugLogger.warn.mockClear(); + mockDebugLogger.error.mockClear(); vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); }); @@ -427,6 +447,32 @@ describe('loggers', () => { ).not.toHaveBeenCalled(); }); + it('logs synchronous token usage recording failures to debug logs', () => { + vi.mocked( + tokenUsageService.recordTokenUsageFromApiResponseBestEffort, + ).mockImplementation(() => { + throw new Error('usage write failed'); + }); + const event = new ApiResponseEvent( + 'test-response-id', + 'test-model', + 100, + 'prompt-id-1', + AuthType.USE_GEMINI, + { + promptTokenCount: 1, + candidatesTokenCount: 2, + }, + ); + + logApiResponse(mockConfig, event); + + expect(mockDebugLogger.warn).toHaveBeenCalledWith( + 'Synchronous token usage recording error:', + expect.any(Error), + ); + }); + it.each([ 'prompt_suggestion', 'forked_query', From ce9a578b15be0909cf19e4f01db42c08d28b56ff Mon Sep 17 00:00:00 2001 From: sheny Date: Wed, 27 May 2026 22:10:35 +0800 Subject: [PATCH 04/13] Refine token usage recording after review Constraint: Address wenshao's latest PR #4564 review suggestions without expanding the /stats command surface. Rejected: Keeping synchronous token-usage writes | sync I/O remains on the API response hot path. Confidence: high Scope-risk: narrow Directive: Keep token usage persistence best-effort and gated by explicit usage-statistics enablement. Tested: cd packages/core; npx vitest run src/services/tokenUsageService.test.ts src/telemetry/loggers.test.ts; cd packages/cli; npx vitest run src/ui/commands/statsCommand.test.ts; npm run typecheck; npm run build; npm run lint --workspace=packages/core; npm run lint --workspace=packages/cli; git diff --check Not-tested: Full repository test suite --- packages/cli/src/ui/commands/statsCommand.ts | 12 ++-- .../src/services/tokenUsageService.test.ts | 67 +++++++++++++------ .../core/src/services/tokenUsageService.ts | 34 +++++++--- 3 files changed, 78 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 9c3f89b1872..30a18e7da00 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -19,8 +19,7 @@ import { import { getCurrentLanguage, t } from '../../i18n/index.js'; import { calculateCost } from '../../utils/costCalculator.js'; import { - formatTokenUsageSummaryAsCsv, - formatTokenUsageSummaryAsJson, + exportTokenUsageSummary, isSubpath, queryTokenUsage, type TokenUsageExportFormat, @@ -566,10 +565,11 @@ async function writeStatsExport( period: parsed.period, value: parsed.value, }); - const content = - parsed.format === 'json' - ? formatTokenUsageSummaryAsJson(summary) - : `${formatTokenUsageSummaryAsCsv(summary)}\n`; + const content = await exportTokenUsageSummary({ + period: summary.period, + value: summary.value, + format: parsed.format, + }); 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); diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index de555fa4034..39924964cd8 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -6,7 +6,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import fs from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import type { GenerateContentResponseUsageMetadata } from '@google/genai'; @@ -199,7 +198,7 @@ describe('tokenUsageService', () => { ]); }); - it('swallows best-effort write errors and surfaces non-ENOENT failures', () => { + it('swallows best-effort write errors and surfaces non-ENOENT failures', async () => { const config = makeFakeConfig({ sessionId: 'session-1', targetDir: path.join(tempDir, 'project'), @@ -210,11 +209,7 @@ describe('tokenUsageService', () => { totalTokenCount: 3, }); const error = Object.assign(new Error('disk full'), { code: 'ENOSPC' }); - const writeSpy = vi - .spyOn(jsonl, 'writeLineSync') - .mockImplementationOnce(() => { - throw error; - }); + const writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); const stderrSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); @@ -225,9 +220,45 @@ describe('tokenUsageService', () => { ).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 (ENOSPC):', - 'disk full', + '[token-usage] Write failed (EACCES):', + 'session unavailable', ); } finally { writeSpy.mockRestore(); @@ -235,7 +266,7 @@ describe('tokenUsageService', () => { } }); - it('does not surface best-effort ENOENT write failures to stderr', () => { + it('does not surface best-effort ENOENT write failures to stderr', async () => { const config = makeFakeConfig({ sessionId: 'session-1', targetDir: path.join(tempDir, 'project'), @@ -248,11 +279,7 @@ describe('tokenUsageService', () => { const error = Object.assign(new Error('missing directory'), { code: 'ENOENT', }); - const writeSpy = vi - .spyOn(jsonl, 'writeLineSync') - .mockImplementationOnce(() => { - throw error; - }); + const writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); const stderrSpy = vi .spyOn(console, 'error') .mockImplementation(() => undefined); @@ -261,6 +288,9 @@ describe('tokenUsageService', () => { recordTokenUsageFromApiResponseBestEffort(config, event); expect(writeSpy).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(writeSpy).toHaveBeenCalledTimes(1); + }); expect(stderrSpy).not.toHaveBeenCalled(); } finally { writeSpy.mockRestore(); @@ -513,7 +543,7 @@ describe('tokenUsageService', () => { ); }); - it('persists best-effort records synchronously', () => { + it('persists best-effort records asynchronously without blocking callers', async () => { const config = makeFakeConfig({ sessionId: 'session-1', targetDir: path.join(tempDir, 'project'), @@ -528,9 +558,8 @@ describe('tokenUsageService', () => { }), ); - const fileContent = fs.readFileSync( - getTokenUsageFilePath('2026-05'), - 'utf-8', + const fileContent = await vi.waitFor(() => + readFile(getTokenUsageFilePath('2026-05'), 'utf-8'), ); expect(fileContent).toContain('"model":"model-a"'); }); diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index 4709ddffacb..160788385dd 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -27,7 +27,15 @@ 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; @@ -358,23 +366,29 @@ export async function recordTokenUsageFromApiResponse( await jsonl.writeLine(getTokenUsageFilePath(record.localMonth), record); } +function logTokenUsageWriteFailure(error: unknown): void { + debugLogger.warn('Failed to record token usage:', error); + const code = (error as NodeJS.ErrnoException).code; + if (code && code !== 'ENOENT') { + // eslint-disable-next-line no-console -- surface persistent local write failures outside debug mode + console.error( + `[token-usage] Write failed (${code}):`, + error instanceof Error ? error.message : String(error), + ); + } +} + export function recordTokenUsageFromApiResponseBestEffort( config: Config, event: ApiResponseEvent, ): void { try { const record = apiResponseEventToTokenUsageRecord(config, event); - jsonl.writeLineSync(getTokenUsageFilePath(record.localMonth), record); + void jsonl + .writeLine(getTokenUsageFilePath(record.localMonth), record) + .catch(logTokenUsageWriteFailure); } catch (error) { - debugLogger.warn('Failed to record token usage:', error); - const code = (error as NodeJS.ErrnoException).code; - if (code && code !== 'ENOENT') { - // eslint-disable-next-line no-console -- surface persistent local write failures outside debug mode - console.error( - `[token-usage] Write failed (${code}):`, - error instanceof Error ? error.message : String(error), - ); - } + logTokenUsageWriteFailure(error); } } From c97a6b1c1f670222d4984f7fd0ef4a26688b1f90 Mon Sep 17 00:00:00 2001 From: sheny Date: Thu, 28 May 2026 00:13:40 +0800 Subject: [PATCH 05/13] fix(stats): avoid silent zero usage on read failures Propagate token usage read failures through the existing /stats error path while keeping missing usage files empty, and remove the unreachable telemetry wrapper catch. Constraint: PR #4564 review requested user-visible read failures, full i18n for export errors, and removal of dead telemetry catch code. Rejected: Adding warning fields to TokenUsageSummary | It would expand the JSON/export schema when the existing command error path already fits read failures. Confidence: high Scope-risk: narrow Directive: Keep jsonl.read default swallowing behavior for existing session/history callers unless a user-visible caller opts into rethrowing non-ENOENT errors. Tested: npx vitest run src/utils/jsonl-utils.test.ts src/services/tokenUsageService.test.ts src/telemetry/loggers.test.ts Tested: npx vitest run src/ui/commands/statsCommand.test.ts Tested: npm run check-i18n --workspace=packages/cli Tested: npx prettier --check changed files Tested: npm run typecheck Tested: npm run lint --workspace=packages/core Tested: npm run lint --workspace=packages/cli Tested: git diff --check Tested: npm run build Not-tested: Full integration test suite --- packages/cli/src/i18n/locales/ca.js | 4 + packages/cli/src/i18n/locales/de.js | 4 + packages/cli/src/i18n/locales/fr.js | 4 + packages/cli/src/i18n/locales/ja.js | 4 + packages/cli/src/i18n/locales/pt.js | 4 + packages/cli/src/i18n/locales/ru.js | 4 + .../src/services/tokenUsageService.test.ts | 18 ++++ .../core/src/services/tokenUsageService.ts | 4 +- packages/core/src/telemetry/loggers.test.ts | 95 ------------------- packages/core/src/utils/jsonl-utils.test.ts | 26 +++++ packages/core/src/utils/jsonl-utils.ts | 12 ++- 11 files changed, 82 insertions(+), 97 deletions(-) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 868cdd27518..cfec16960b3 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -546,6 +546,10 @@ export default { 'Ú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.", + '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}}': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 934fe656f4a..5a5515bc9cd 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -478,6 +478,10 @@ export default { '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.', + '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}}': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index daf959b4141..7171827ab86 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -554,6 +554,10 @@ export default { '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.", + '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}}': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index d1ac052a9c2..21aa5b19025 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -412,6 +412,10 @@ export default { '使い方: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', 'Token usage export path must be within the project working directory.': 'token 使用量のエクスポート先はプロジェクト作業ディレクトリ内である必要があります。', + '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}}': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index e9aae5472da..2ecaef47a8c 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -501,6 +501,10 @@ export default { '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.', + '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}}': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 32eb84ad8e8..2c72add7462 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -497,6 +497,10 @@ export default { 'Использование: /stats export [YYYY-MM-DD|YYYY-MM] [--format csv|json] [--output path]', 'Token usage export path must be within the project working directory.': 'Путь экспорта использования токенов должен находиться внутри рабочего каталога проекта.', + '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}}': diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index 39924964cd8..1458b86fbb5 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -457,6 +457,24 @@ describe('tokenUsageService', () => { 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 () => { const filePath = getTokenUsageFilePath('2026-05'); await mkdir(path.dirname(filePath), { recursive: true }); diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index 160788385dd..90fe23714b8 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -281,7 +281,9 @@ function isTokenUsageRecord(value: unknown): value is TokenUsageRecord { async function readRecordsForMonth(month: string): Promise { const filePath = getTokenUsageFilePath(month); - const records = await jsonl.read(filePath); + const records = await jsonl.read(filePath, { + throwOnNonEnoentError: true, + }); return records.filter(isTokenUsageRecord); } diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 628e6a84e48..387a588fc1b 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -95,22 +95,6 @@ import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import * as uiTelemetry from './uiTelemetry.js'; import { makeFakeConfig } from '../test-utils/config.js'; -const mockDebugLogger = vi.hoisted(() => ({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -})); - -vi.mock('../utils/debugLogger.js', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - createDebugLogger: vi.fn(() => mockDebugLogger), - }; -}); - describe('loggers', () => { const mockLogger = { emit: vi.fn(), @@ -126,10 +110,6 @@ describe('loggers', () => { vi.spyOn(uiTelemetry.uiTelemetryService, 'addEvent').mockImplementation( mockUiEvent.addEvent, ); - mockDebugLogger.debug.mockClear(); - mockDebugLogger.info.mockClear(); - mockDebugLogger.warn.mockClear(); - mockDebugLogger.error.mockClear(); vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z')); }); @@ -446,81 +426,6 @@ describe('loggers', () => { tokenUsageService.recordTokenUsageFromApiResponseBestEffort, ).not.toHaveBeenCalled(); }); - - it('logs synchronous token usage recording failures to debug logs', () => { - vi.mocked( - tokenUsageService.recordTokenUsageFromApiResponseBestEffort, - ).mockImplementation(() => { - throw new Error('usage write failed'); - }); - const event = new ApiResponseEvent( - 'test-response-id', - 'test-model', - 100, - 'prompt-id-1', - AuthType.USE_GEMINI, - { - promptTokenCount: 1, - candidatesTokenCount: 2, - }, - ); - - logApiResponse(mockConfig, event); - - expect(mockDebugLogger.warn).toHaveBeenCalledWith( - 'Synchronous token usage recording error:', - expect.any(Error), - ); - }); - - 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', () => { 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 { From 63ca4aa5c9b458e30ba37b86755781315c109893 Mon Sep 17 00:00:00 2001 From: sheny Date: Thu, 28 May 2026 12:06:56 +0800 Subject: [PATCH 06/13] fix: close token usage review gaps Keep the review follow-ups local to token usage accounting and stats export without adding new abstractions. Constraint: Address PR #4564 reviewer requests on token usage export/query reuse, write-failure stderr noise, and invalid-record diagnostics. Confidence: high Scope-risk: narrow Directive: Keep token usage writes best-effort and avoid noisy stderr loops for repeated local failures. Tested: git diff --check; prior targeted core/cli tests, typecheck, and lint passed for this working tree. Not-tested: Full repository test suite. --- packages/cli/src/ui/commands/statsCommand.ts | 12 ++-- .../src/services/tokenUsageService.test.ts | 63 +++++++++++++++++++ .../core/src/services/tokenUsageService.ts | 44 +++++++++++-- 3 files changed, 107 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 30a18e7da00..9c3f89b1872 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -19,7 +19,8 @@ import { import { getCurrentLanguage, t } from '../../i18n/index.js'; import { calculateCost } from '../../utils/costCalculator.js'; import { - exportTokenUsageSummary, + formatTokenUsageSummaryAsCsv, + formatTokenUsageSummaryAsJson, isSubpath, queryTokenUsage, type TokenUsageExportFormat, @@ -565,11 +566,10 @@ async function writeStatsExport( period: parsed.period, value: parsed.value, }); - const content = await exportTokenUsageSummary({ - period: summary.period, - value: summary.value, - format: parsed.format, - }); + 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); diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index 1458b86fbb5..834ae4d0933 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -15,6 +15,7 @@ import { makeFakeConfig } from '../test-utils/config.js'; import { ApiResponseEvent } from '../telemetry/types.js'; import * as jsonl from '../utils/jsonl-utils.js'; import { + __overrideNowForTesting, apiResponseEventToTokenUsageRecord, exportTokenUsageSummary, formatTokenUsageSummaryAsCsv, @@ -22,6 +23,7 @@ import { queryTokenUsage, recordTokenUsageFromApiResponse, recordTokenUsageFromApiResponseBestEffort, + resetTokenUsageFailureLogging, } from './tokenUsageService.js'; describe('tokenUsageService', () => { @@ -38,6 +40,7 @@ describe('tokenUsageService', () => { afterEach(async () => { vi.useRealTimers(); + resetTokenUsageFailureLogging(); if (originalRuntimeDir === undefined) { delete process.env['QWEN_RUNTIME_DIR']; } else { @@ -266,6 +269,66 @@ describe('tokenUsageService', () => { } }); + 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(); + } + + // --- Advance time past cooldown: should log again --- + fakeNow += 61_000; + writeSpy = vi.spyOn(jsonl, 'writeLine').mockRejectedValueOnce(error); + try { + recordTokenUsageFromApiResponseBestEffort(config, event); + await vi.waitFor(() => { + expect(stderrSpy).toHaveBeenCalledTimes(2); + }); + } finally { + writeSpy.mockRestore(); + } + + stderrSpy.mockRestore(); + }); + it('does not surface best-effort ENOENT write failures to stderr', async () => { const config = makeFakeConfig({ sessionId: 'session-1', diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index 90fe23714b8..04b348102fc 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -284,7 +284,19 @@ async function readRecordsForMonth(month: string): Promise { const records = await jsonl.read(filePath, { throwOnNonEnoentError: true, }); - return records.filter(isTokenUsageRecord); + 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} invalid record(s) from ${filePath}`); + } + return valid; } function summarizeRecords( @@ -368,18 +380,38 @@ export async function recordTokenUsageFromApiResponse( await jsonl.writeLine(getTokenUsageFilePath(record.localMonth), record); } +const lastLoggedTimeByCode = 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') { - // eslint-disable-next-line no-console -- surface persistent local write failures outside debug mode - console.error( - `[token-usage] Write failed (${code}):`, - error instanceof Error ? error.message : String(error), - ); + const now = _now(); + const lastTime = lastLoggedTimeByCode.get(code) ?? 0; + if (now - lastTime > TOKEN_USAGE_FAILURE_LOG_COOLDOWN_MS) { + lastLoggedTimeByCode.set(code, now); + // eslint-disable-next-line no-console -- surface persistent local write failures outside debug mode + console.error( + `[token-usage] Write failed (${code}):`, + error instanceof Error ? error.message : String(error), + ); + } } } +/** @internal Reset token usage failure rate-limiting state. For testing only. */ +export function resetTokenUsageFailureLogging(): void { + lastLoggedTimeByCode.clear(); + _now = () => Date.now(); +} + export function recordTokenUsageFromApiResponseBestEffort( config: Config, event: ApiResponseEvent, From 8778a92ee2e4e01128d8884cb0c4637706758f22 Mon Sep 17 00:00:00 2001 From: sheny Date: Sat, 30 May 2026 12:45:40 +0800 Subject: [PATCH 07/13] fix(stats): address token usage review feedback --- .../src/services/tokenUsageService.test.ts | 43 ++++++++++++++++++- .../core/src/services/tokenUsageService.ts | 18 +++++++- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index 834ae4d0933..7036466b2d3 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -13,6 +13,7 @@ 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, @@ -29,23 +30,31 @@ import { 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 }); }); @@ -314,14 +323,35 @@ describe('tokenUsageService', () => { writeSpy.mockRestore(); } - // --- Advance time past cooldown: should log again --- + 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(2); + expect(stderrSpy).toHaveBeenCalledTimes(3); }); + expect(stderrSpy).toHaveBeenLastCalledWith( + '[token-usage] Write failed (ENOSPC):', + 'disk full (1 similar suppressed in last window)', + ); } finally { writeSpy.mockRestore(); } @@ -539,12 +569,17 @@ describe('tokenUsageService', () => { }); 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,"id":"invalid"}', 'not-json', ].join('\n'), 'utf-8', @@ -557,6 +592,10 @@ describe('tokenUsageService', () => { 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 1/2 invalid record(s) from ${filePath}`); + }); }); it('reads older compatible schema versions', async () => { diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index 04b348102fc..9b2e851e088 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -294,7 +294,9 @@ async function readRecordsForMonth(month: string): Promise { } } if (dropped > 0) { - debugLogger.warn(`Dropped ${dropped} invalid record(s) from ${filePath}`); + debugLogger.warn( + `Dropped ${dropped}/${records.length} invalid record(s) from ${filePath}`, + ); } return valid; } @@ -381,6 +383,7 @@ export async function recordTokenUsageFromApiResponse( } const lastLoggedTimeByCode = new Map(); +const suppressedCountByCode = new Map(); const TOKEN_USAGE_FAILURE_LOG_COOLDOWN_MS = 60_000; let _now: () => number = () => Date.now(); @@ -397,10 +400,20 @@ function logTokenUsageWriteFailure(error: unknown): void { 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}):`, - error instanceof Error ? error.message : String(error), + suppressedCount > 0 + ? `${message} (${suppressedCount} similar suppressed in last window)` + : message, + ); + } else { + suppressedCountByCode.set( + code, + (suppressedCountByCode.get(code) ?? 0) + 1, ); } } @@ -409,6 +422,7 @@ function logTokenUsageWriteFailure(error: unknown): void { /** @internal Reset token usage failure rate-limiting state. For testing only. */ export function resetTokenUsageFailureLogging(): void { lastLoggedTimeByCode.clear(); + suppressedCountByCode.clear(); _now = () => Date.now(); } From 88e9bee242d42edb710e6e9d875f8b5f90ea33ed Mon Sep 17 00:00:00 2001 From: Yan Shen Date: Sat, 30 May 2026 15:40:52 +0800 Subject: [PATCH 08/13] Update packages/core/src/services/tokenUsageService.ts Co-authored-by: Shaojin Wen --- packages/core/src/services/tokenUsageService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index 9b2e851e088..3f0d2fb49fd 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -407,7 +407,7 @@ function logTokenUsageWriteFailure(error: unknown): void { console.error( `[token-usage] Write failed (${code}):`, suppressedCount > 0 - ? `${message} (${suppressedCount} similar suppressed in last window)` + ? `${message} (${suppressedCount} similar suppressed since last log)` : message, ); } else { From 3a04fb55cf84bdc8c35dc68f1f427ba1d867b66b Mon Sep 17 00:00:00 2001 From: sheny Date: Sat, 30 May 2026 16:10:42 +0800 Subject: [PATCH 09/13] test(core): keep token usage stderr assertion current Keep the repeated write-failure regression test aligned with the runtime wording that the PR now emits. Constraint: PR #4564 CI failed after the implementation wording changed to "since last log". Rejected: Reverting the implementation wording | it is the latest PR behavior and the failure is test-only. Confidence: high Scope-risk: narrow Tested: cd packages/core && npx vitest run src/services/tokenUsageService.test.ts Not-tested: full repository test suite --- packages/core/src/services/tokenUsageService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index 7036466b2d3..39290878c10 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -350,7 +350,7 @@ describe('tokenUsageService', () => { }); expect(stderrSpy).toHaveBeenLastCalledWith( '[token-usage] Write failed (ENOSPC):', - 'disk full (1 similar suppressed in last window)', + 'disk full (1 similar suppressed since last log)', ); } finally { writeSpy.mockRestore(); From 691eb76dff20fdaead165b61941da9a8720c2afa Mon Sep 17 00:00:00 2001 From: sheny Date: Tue, 9 Jun 2026 08:32:23 +0800 Subject: [PATCH 10/13] fix(stats): clarify export review edge cases Address the remaining PR review polish without changing token accounting, export formats, or path containment behavior. Constraint: Review 4452925552 requested narrow documentation, ENOENT wording, and NOTICES cleanup only. Rejected: Broader merge-conflict rework | GitHub currently reports the PR as mergeable, and the requested fixes are review polish. Confidence: high Scope-risk: narrow Directive: Keep token usage records content-free and preserve export path validation semantics except for the final ENOENT message. Tested: cd packages/core && npx vitest run src/services/tokenUsageService.test.ts; cd packages/cli && npx vitest run src/ui/commands/statsCommand.test.ts; npm run check-i18n --workspace=packages/cli; npm run typecheck; git diff --check on changed code and i18n files Not-tested: Full test suite not run. --- packages/cli/src/i18n/locales/ca.js | 2 ++ packages/cli/src/i18n/locales/de.js | 2 ++ packages/cli/src/i18n/locales/fr.js | 2 ++ packages/cli/src/i18n/locales/ja.js | 2 ++ packages/cli/src/i18n/locales/pt.js | 2 ++ packages/cli/src/i18n/locales/ru.js | 2 ++ packages/core/src/services/tokenUsageService.ts | 2 ++ 7 files changed, 14 insertions(+) diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index cfec16960b3..3cb916352d2 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -546,6 +546,8 @@ export default { 'Ú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.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 5a5515bc9cd..c6aa79e5750 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -478,6 +478,8 @@ export default { '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.': diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 7171827ab86..d79f4fe1ec0 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -554,6 +554,8 @@ export default { '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.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 21aa5b19025..e1ffe301ad3 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -412,6 +412,8 @@ export default { '使い方: /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.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 2ecaef47a8c..dacb167ba36 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -501,6 +501,8 @@ export default { '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.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 2c72add7462..0903662237e 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -497,6 +497,8 @@ export default { 'Использование: /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.': diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index 3f0d2fb49fd..b24eafe7685 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -211,6 +211,8 @@ function calculateInputTokens(event: ApiResponseEvent): number { 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); } From 2cad6e503051a27a45fe5342b888194bbd1bdc82 Mon Sep 17 00:00:00 2001 From: sheny Date: Mon, 15 Jun 2026 21:54:40 +0800 Subject: [PATCH 11/13] fix: address token usage review feedback --- packages/cli/src/ui/commands/statsCommand.ts | 3 -- .../src/services/tokenUsageService.test.ts | 5 +++- .../core/src/services/tokenUsageService.ts | 30 ++++++++++++++----- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 9c3f89b1872..f1cb4325bb0 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -193,9 +193,6 @@ function tokenizeArgs(args: string): string[] { current += char; } - if (quote !== undefined) { - throw new Error(t('Unclosed quote in arguments.')); - } if (quote !== undefined) { throw new Error(t('Unclosed quote in arguments.')); } diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index 39290878c10..06915b3df23 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -579,6 +579,8 @@ describe('tokenUsageService', () => { 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'), @@ -594,7 +596,7 @@ describe('tokenUsageService', () => { expect(summary.totals.requests).toBe(1); await vi.waitFor(async () => { const log = await readFile(Storage.getDebugLogPath(sessionId), 'utf-8'); - expect(log).toContain(`Dropped 1/2 invalid record(s) from ${filePath}`); + expect(log).toContain(`Dropped 3/4 invalid record(s) from ${filePath}`); }); }); @@ -655,6 +657,7 @@ describe('tokenUsageService', () => { 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", ); diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index b24eafe7685..ae91b732853 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -262,6 +262,8 @@ function isTokenUsageRecord(value: unknown): value is TokenUsageRecord { } 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 && @@ -467,10 +469,14 @@ function csvEscape(value: string | number | undefined): string { } function groupRows( + period: TokenUsagePeriod, + value: string, groupType: string, groups: TokenUsageGroupSummary[], ): string[][] { return groups.map((group) => [ + period, + value, groupType, group.key, group.model ?? '', @@ -507,6 +513,8 @@ export function formatTokenUsageSummaryAsCsv( ]; const rows = [ [ + summary.period, + summary.value, 'total', 'total', '', @@ -520,17 +528,25 @@ export function formatTokenUsageSummaryAsCsv( String(summary.totals.totalTokens), String(summary.totals.apiDurationMs), ], - ...groupRows('model', summary.byModel), - ...groupRows('auth_type', summary.byAuthType), - ...groupRows('model_auth_type', summary.byModelAndAuthType), - ...groupRows('source', summary.bySource), + ...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) => - [summary.period, summary.value, ...row].map(csvEscape).join(','), - ), + ...rows.map((row) => row.map(csvEscape).join(',')), ].join('\n'); } From b26eb1bba520ddf7c24b1ae79d3e8a6fc9af09e3 Mon Sep 17 00:00:00 2001 From: sheny Date: Tue, 16 Jun 2026 11:38:08 +0800 Subject: [PATCH 12/13] fix(stats): harden token usage CSV export --- .../src/services/tokenUsageService.test.ts | 104 ++++++++++++++++++ .../core/src/services/tokenUsageService.ts | 8 +- 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/packages/core/src/services/tokenUsageService.test.ts b/packages/core/src/services/tokenUsageService.test.ts index 06915b3df23..ddfa7a26dd5 100644 --- a/packages/core/src/services/tokenUsageService.test.ts +++ b/packages/core/src/services/tokenUsageService.test.ts @@ -130,6 +130,40 @@ describe('tokenUsageService', () => { 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', @@ -666,6 +700,76 @@ describe('tokenUsageService', () => { ); }); + 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', diff --git a/packages/core/src/services/tokenUsageService.ts b/packages/core/src/services/tokenUsageService.ts index ae91b732853..fe453395599 100644 --- a/packages/core/src/services/tokenUsageService.ts +++ b/packages/core/src/services/tokenUsageService.ts @@ -459,9 +459,11 @@ export async function queryTokenUsage( function csvEscape(value: string | number | undefined): string { const stringValue = value === undefined ? '' : String(value); - const sanitized = /^[=+\-@\t\r\n]/.test(stringValue) - ? `'${stringValue}` - : stringValue; + const trimmed = stringValue.trimStart(); + const sanitized = + /^[=+\-@]/.test(trimmed) || /^[\t\r\n]/.test(stringValue) + ? `'${stringValue}` + : stringValue; if (/[",\n\r]/.test(sanitized)) { return `"${sanitized.replace(/"/g, '""')}"`; } From 19edc2c0c91b95e5ec6cb3a74b0aa42e768ee54e Mon Sep 17 00:00:00 2001 From: sheny Date: Thu, 18 Jun 2026 10:55:46 +0800 Subject: [PATCH 13/13] fix(stats): remove unrelated auto mode noise --- packages/cli/src/i18n/locales/ca.js | 19 --- packages/cli/src/i18n/locales/de.js | 20 --- packages/cli/src/i18n/locales/en.js | 19 --- packages/cli/src/i18n/locales/fr.js | 20 --- packages/cli/src/i18n/locales/ja.js | 18 --- packages/cli/src/i18n/locales/pt.js | 20 --- packages/cli/src/i18n/locales/ru.js | 20 --- packages/cli/src/i18n/locales/zh-TW.js | 18 --- packages/cli/src/i18n/locales/zh.js | 18 --- packages/cli/src/i18n/mustTranslateKeys.ts | 11 -- .../components/AutoAcceptIndicator.test.tsx | 34 ----- .../ui/hooks/useAutoAcceptIndicator.test.ts | 131 ------------------ .../src/ui/hooks/useAutoAcceptIndicator.ts | 37 ++--- 13 files changed, 13 insertions(+), 372 deletions(-) delete mode 100644 packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index 3cb916352d2..52526452887 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -25,23 +25,6 @@ export default { 'Auto mode': 'Mode auto', 'plan mode': 'mode de planificació', 'auto-accept edits': 'acceptació automàtica de canvis', - 'auto mode (classifier-evaluated)': 'mode automàtic (classificador avaluat)', - 'Auto mode enabled.': 'Mode automàtic activat.', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - "Un classificador LLM avalua cada crida d'eina i aprova automàticament les accions segures,", - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - "bloqueja les arriscades. La majoria d'operacions de només lectura i edicions dins del directori actual ometen el", - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - 'classificador per rapidesa. Per sortir: Shift+Tab o /approval-mode default.', - '(This notice will not appear again.)': - '(Aquest avís no tornarà a aparèixer.)', - 'Auto mode temporarily disabled these allow rules': - 'El mode automàtic ha desactivat temporalment aquestes regles de permís', - '(they would bypass the classifier):': '(ometrian el classificador):', - '(from user settings)': "(de la configuració de l'usuari)", - '(session)': '(sessió)', - 'These will be restored when leaving auto mode.': - 'Es restauraran en sortir del mode automàtic.', 'Accepting edits': 'Acceptant canvis', '(shift + tab to cycle)': '(Shift + Tab per canviar)', '(tab to cycle)': '(Tab per canviar)', @@ -506,8 +489,6 @@ 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|daily|monthly|export]': - 'comprovar les estadístiques de la sessió. Ús: /stats [model|tools|daily|monthly|export]', 'Show model-specific usage statistics.': "Mostrar les estadístiques d'ús específiques del model.", 'Show tool-specific usage statistics.': diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index c6aa79e5750..b205f4bae7a 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -27,24 +27,6 @@ export default { 'Auto mode': 'Auto-Modus', 'plan mode': 'Planungsmodus', 'auto-accept edits': 'Änderungen automatisch akzeptieren', - 'auto mode (classifier-evaluated)': 'Automodus (Klassifizierer ausgewertet)', - 'Auto mode enabled.': 'Automodus aktiviert.', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - 'Ein LLM-Klassifizierer bewertet jeden Werkzeugaufruf und genehmigt sichere Aktionen automatisch,', - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - 'blockiert riskante Aktionen. Die meisten Nur-Lese-Operationen und Bearbeitungen im Arbeitsverzeichnis überspringen den', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - 'Klassifizierer aus Geschwindigkeitsgründen. Beenden: Shift+Tab oder /approval-mode default.', - '(This notice will not appear again.)': - '(Dieser Hinweis wird nicht erneut angezeigt.)', - 'Auto mode temporarily disabled these allow rules': - 'Der Automodus hat diese Zulassungsregeln vorübergehend deaktiviert', - '(they would bypass the classifier):': - '(sie würden den Klassifizierer umgehen):', - '(from user settings)': '(aus Benutzereinstellungen)', - '(session)': '(Sitzung)', - 'These will be restored when leaving auto mode.': - 'Diese werden beim Verlassen des Automodus wiederhergestellt.', 'Accepting edits': 'Änderungen werden akzeptiert', '(shift + tab to cycle)': '(Shift + Tab zum Wechseln)', '(tab to cycle)': '(Tab zum Wechseln)', @@ -438,8 +420,6 @@ export default { 'Auto Edit': 'Automatisch bearbeiten', YOLO: 'YOLO', 'toggle vim mode on/off': 'Vim-Modus ein-/ausschalten', - 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': - 'Sitzungsstatistiken prüfen. Verwendung: /stats [model|tools|daily|monthly|export]', 'Show model-specific usage statistics.': 'Modellspezifische Nutzungsstatistiken anzeigen.', 'Show tool-specific usage statistics.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 5cf9a2e66a3..abea052dfae 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -27,23 +27,6 @@ export default { 'Auto mode': 'Auto mode', 'plan mode': 'plan mode', 'auto-accept edits': 'auto-accept edits', - 'auto mode (classifier-evaluated)': 'auto mode (classifier-evaluated)', - 'Auto mode enabled.': 'Auto mode enabled.', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - 'An LLM classifier evaluates each tool call and auto-approves safe actions,', - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - 'blocks risky ones. Most read-only operations and in-cwd edits skip the', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.', - '(This notice will not appear again.)': - '(This notice will not appear again.)', - 'Auto mode temporarily disabled these allow rules': - 'Auto mode temporarily disabled these allow rules', - '(they would bypass the classifier):': '(they would bypass the classifier):', - '(from user settings)': '(from user settings)', - '(session)': '(session)', - 'These will be restored when leaving auto mode.': - 'These will be restored when leaving auto mode.', 'Accepting edits': 'Accepting edits', '(shift + tab to cycle)': '(shift + tab to cycle)', '(tab to cycle)': '(tab to cycle)', @@ -533,8 +516,6 @@ export default { 'Auto Edit': 'Auto Edit', YOLO: 'YOLO', 'toggle vim mode on/off': 'toggle vim mode on/off', - 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': - 'check session stats. Usage: /stats [model|tools|daily|monthly|export]', 'Show usage statistics dashboard.': 'Show usage statistics dashboard.', 'Show model-specific usage statistics.': 'Show model-specific usage statistics.', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index d79f4fe1ec0..737a247388c 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -25,24 +25,6 @@ export default { 'Auto mode': 'Mode auto', 'plan mode': 'mode plan', 'auto-accept edits': 'acceptation automatique des modifications', - 'auto mode (classifier-evaluated)': - 'mode automatique (classificateur évalué)', - 'Auto mode enabled.': 'Mode automatique activé.', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - "Un classificateur LLM évalue chaque appel d'outil et approuve automatiquement les actions sûres,", - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - 'bloque les actions risquées. La plupart des opérations en lecture seule et des modifications dans le dossier courant ignorent le', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - 'classificateur pour plus de rapidité. Pour quitter : Shift+Tab ou /approval-mode default.', - '(This notice will not appear again.)': "(Cet avis ne s'affichera plus.)", - 'Auto mode temporarily disabled these allow rules': - "Le mode automatique a temporairement désactivé ces règles d'autorisation", - '(they would bypass the classifier):': - '(elles contourneraient le classificateur) :', - '(from user settings)': '(depuis les paramètres utilisateur)', - '(session)': '(session actuelle)', - 'These will be restored when leaving auto mode.': - 'Elles seront restaurées à la sortie du mode automatique.', 'Accepting edits': 'Acceptation des modifications', '(shift + tab to cycle)': '(Shift + Tab pour cycler)', '(tab to cycle)': '(Tab pour cycler)', @@ -512,8 +494,6 @@ 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|daily|monthly|export]': - 'vérifier les stats de session. Utilisation : /stats [modèle|outils|daily|monthly|export]', 'Show model-specific usage statistics.': "Afficher les statistiques d'utilisation spécifiques au modèle.", 'Show tool-specific usage statistics.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index e1ffe301ad3..cd9e3bcbf4f 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -21,22 +21,6 @@ export default { 'Auto mode': 'Autoモード', 'plan mode': 'プランモード', 'auto-accept edits': '編集を自動承認', - 'auto mode (classifier-evaluated)': '自動モード(分類器評価済み)', - 'Auto mode enabled.': '自動モードが有効になりました。', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - 'LLM 分類器が各ツール呼び出しを評価し、安全な操作を自動承認し、', - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - 'リスクのある操作をブロックします。多くの読み取り専用操作と作業ディレクトリ内の編集は', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - '速度向上のため分類器をスキップします。終了: Shift+Tab または /approval-mode default。', - '(This notice will not appear again.)': '(この通知は再表示されません。)', - 'Auto mode temporarily disabled these allow rules': - '自動モードが次の許可ルールを一時的に無効化しました', - '(they would bypass the classifier):': '(分類器をバイパスするため):', - '(from user settings)': '(ユーザー設定から)', - '(session)': '(セッション)', - 'These will be restored when leaving auto mode.': - '自動モードを終了すると復元されます。', 'Accepting edits': '編集を承認中', '(shift + tab to cycle)': '(Shift + Tab で切り替え)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': @@ -376,8 +360,6 @@ export default { 'Auto (detect from system)': '自動(システムから検出)', 'Auto (detect terminal theme)': '自動(端末テーマを検出)', Auto: '自動', - 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': - 'セッション統計を確認。使い方: /stats [model|tools|daily|monthly|export]', 'Show model-specific usage statistics.': 'モデル別の使用統計を表示', 'Show tool-specific usage statistics.': 'ツール別の使用統計を表示', 'Show daily token usage statistics.': '日次 token 使用統計を表示', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index dacb167ba36..e74043465ed 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -21,24 +21,6 @@ export default { 'Auto mode': 'Modo auto', 'plan mode': 'modo planejamento', 'auto-accept edits': 'aceitar edições automaticamente', - 'auto mode (classifier-evaluated)': - 'modo automático (classificador avaliado)', - 'Auto mode enabled.': 'Modo automático ativado.', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - 'Um classificador LLM avalia cada chamada de ferramenta e aprova automaticamente ações seguras,', - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - 'bloqueia as arriscadas. A maioria das operações somente leitura e edições no diretório atual ignoram o', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - 'classificador por velocidade. Para sair: Shift+Tab ou /approval-mode default.', - '(This notice will not appear again.)': - '(Este aviso não aparecerá novamente.)', - 'Auto mode temporarily disabled these allow rules': - 'O modo automático desativou temporariamente estas regras de permissão', - '(they would bypass the classifier):': '(elas contornariam o classificador):', - '(from user settings)': '(das configurações do usuário)', - '(session)': '(sessão)', - 'These will be restored when leaving auto mode.': - 'Elas serão restauradas ao sair do modo automático.', 'Accepting edits': 'Aceitando edições', '(shift + tab to cycle)': '(Shift + Tab para alternar)', 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': @@ -461,8 +443,6 @@ 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|daily|monthly|export]': - 'verificar estatísticas da sessão. Uso: /stats [model|tools|daily|monthly|export]', 'Show model-specific usage statistics.': 'Mostrar estatísticas de uso específicas do modelo.', 'Show tool-specific usage statistics.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 0903662237e..97d40499d3b 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -27,24 +27,6 @@ export default { 'Auto mode': 'Автоматический режим', 'plan mode': 'Режим планирования', 'auto-accept edits': 'Режим принятия правок', - 'auto mode (classifier-evaluated)': - 'Автоматический режим (классификатор выполнен)', - 'Auto mode enabled.': 'Автоматический режим включен.', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - 'LLM-классификатор оценивает каждый вызов инструмента и автоматически одобряет безопасные действия,', - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - 'блокируя рискованные. Большинство операций только для чтения и правок в рабочем каталоге пропускают', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - 'классификатор для скорости. Выход: Shift+Tab или /approval-mode default.', - '(This notice will not appear again.)': - '(Это уведомление больше не появится.)', - 'Auto mode temporarily disabled these allow rules': - 'Автоматический режим временно отключил эти разрешающие правила', - '(they would bypass the classifier):': '(они обходили бы классификатор):', - '(from user settings)': '(из пользовательских настроек)', - '(session)': '(сеанс)', - 'These will be restored when leaving auto mode.': - 'Они будут восстановлены при выходе из автоматического режима.', 'Accepting edits': 'Принятие правок', '(shift + tab to cycle)': '(Shift + Tab для переключения)', '(tab to cycle)': '(Tab для переключения)', @@ -456,8 +438,6 @@ export default { 'Auto Edit': 'Авторедактирование', YOLO: 'YOLO', 'toggle vim mode on/off': 'Включение/выключение режима vim', - 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': - 'Просмотр статистики сессии. Использование: /stats [model|tools|daily|monthly|export]', 'Show model-specific usage statistics.': 'Показать статистику использования модели.', 'Show tool-specific usage statistics.': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 01ac87e68b9..19acd466df3 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -23,22 +23,6 @@ export default { 'Auto mode': 'Auto 模式', 'plan mode': '規劃模式', 'auto-accept edits': '自動接受編輯', - 'auto mode (classifier-evaluated)': '自動模式(分類器已評估)', - 'Auto mode enabled.': '自動模式已啟用。', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - 'LLM 分類器會評估每次工具呼叫,並自動核准安全操作,', - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - '阻止有風險的操作。多數唯讀操作和目前目錄內編輯會跳過', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - '分類器以提升速度。退出方式:Shift+Tab 或 /approval-mode default。', - '(This notice will not appear again.)': '(此提示不會再次顯示。)', - 'Auto mode temporarily disabled these allow rules': - '自動模式已暫時停用以下允許規則', - '(they would bypass the classifier):': '(它們會繞過分類器):', - '(from user settings)': '(來自使用者設定)', - '(session)': '(會話)', - 'These will be restored when leaving auto mode.': - '退出自動模式時將復原這些規則。', 'Accepting edits': '接受編輯', '(shift + tab to cycle)': '(Shift + Tab 切換)', '(tab to cycle)': '(按 Tab 切換)', @@ -470,8 +454,6 @@ export default { 'Auto Edit': '自動編輯', YOLO: 'YOLO', 'toggle vim mode on/off': '切換 vim 模式開關', - 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': - '檢查會話統計資訊。用法:/stats [model|tools|daily|monthly|export]', 'Show model-specific usage statistics.': '顯示模型相關的使用統計資訊', 'Show tool-specific usage statistics.': '顯示工具相關的使用統計資訊', 'Show daily token usage statistics.': '顯示每日 token 使用統計資訊', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index f74242e18f4..1e468be4aa1 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -25,22 +25,6 @@ export default { 'Auto mode': 'Auto 模式', 'plan mode': '规划模式', 'auto-accept edits': '自动接受编辑', - 'auto mode (classifier-evaluated)': '自动模式(分类器已评估)', - 'Auto mode enabled.': '自动模式已启用。', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,': - 'LLM 分类器会评估每次工具调用,并自动批准安全操作,', - 'blocks risky ones. Most read-only operations and in-cwd edits skip the': - '阻止有风险的操作。多数只读操作和当前目录内编辑会跳过', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.': - '分类器以提升速度。退出方式:Shift+Tab 或 /approval-mode default。', - '(This notice will not appear again.)': '(此提示不会再次显示。)', - 'Auto mode temporarily disabled these allow rules': - '自动模式已临时禁用以下允许规则', - '(they would bypass the classifier):': '(它们会绕过分类器):', - '(from user settings)': '(来自用户设置)', - '(session)': '(会话)', - 'These will be restored when leaving auto mode.': - '退出自动模式时将恢复这些规则。', 'Accepting edits': '接受编辑', '(shift + tab to cycle)': '(Shift + Tab 切换)', '(tab to cycle)': '(按 Tab 切换)', @@ -511,8 +495,6 @@ export default { 'Auto Edit': '自动编辑', YOLO: 'YOLO', 'toggle vim mode on/off': '切换 vim 模式开关', - 'check session stats. Usage: /stats [model|tools|daily|monthly|export]': - '检查会话统计信息。用法:/stats [model|tools|daily|monthly|export]', 'Show usage statistics dashboard.': '显示使用统计面板。', 'Show model-specific usage statistics.': '显示模型相关的使用统计信息', 'Show tool-specific usage statistics.': '显示工具相关的使用统计信息', diff --git a/packages/cli/src/i18n/mustTranslateKeys.ts b/packages/cli/src/i18n/mustTranslateKeys.ts index f8aefdee0c2..cfb6b7538cf 100644 --- a/packages/cli/src/i18n/mustTranslateKeys.ts +++ b/packages/cli/src/i18n/mustTranslateKeys.ts @@ -82,17 +82,6 @@ export const MUST_TRANSLATE_KEYS = [ 'No extensions found.', 'Toggle this help display', 'Toggle shell mode', - 'auto mode (classifier-evaluated)', - 'Auto mode enabled.', - 'An LLM classifier evaluates each tool call and auto-approves safe actions,', - 'blocks risky ones. Most read-only operations and in-cwd edits skip the', - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.', - '(This notice will not appear again.)', - 'Auto mode temporarily disabled these allow rules', - '(they would bypass the classifier):', - '(from user settings)', - '(session)', - 'These will be restored when leaving auto mode.', 'Open command menu', 'Add file context', 'Accept suggestion / Autocomplete', diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx deleted file mode 100644 index a2aeafed8ea..00000000000 --- a/packages/cli/src/ui/components/AutoAcceptIndicator.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { render } from 'ink-testing-library'; -import { afterAll, beforeEach, describe, expect, it } from 'vitest'; -import { ApprovalMode } from '@qwen-code/qwen-code-core'; -import { setLanguageAsync } from '../../i18n/index.js'; -import { AutoAcceptIndicator } from './AutoAcceptIndicator.js'; - -describe('', () => { - beforeEach(async () => { - await setLanguageAsync('en'); - }); - - afterAll(async () => { - await setLanguageAsync('en'); - }); - - it('localizes AUTO mode classifier status in Chinese', async () => { - await setLanguageAsync('zh'); - - const { lastFrame } = render( - , - ); - - const output = lastFrame() ?? ''; - expect(output).toContain('自动模式(分类器已评估)'); - expect(output).not.toContain('auto mode'); - expect(output).not.toContain('classifier-evaluated'); - }); -}); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts index 6ac7ef67b97..30d34677179 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.test.ts @@ -10,7 +10,6 @@ import { expect, vi, beforeEach, - afterEach, type MockedFunction, type Mock, } from 'vitest'; @@ -22,8 +21,6 @@ import type { Config as ActualConfigType } from '@qwen-code/qwen-code-core'; import type { Key } from './useKeypress.js'; import { useKeypress } from './useKeypress.js'; import { MessageType } from '../types.js'; -import { SettingScope } from '../../config/settings.js'; -import { setLanguageAsync } from '../../i18n/index.js'; vi.mock('./useKeypress.js'); @@ -125,10 +122,6 @@ describe('useAutoAcceptIndicator', () => { mockConfigInstance = new (Config as any)() as MockConfigInstanceShape; }); - afterEach(async () => { - await setLanguageAsync('en'); - }); - it('should initialize with ApprovalMode.AUTO_EDIT if config.getApprovalMode returns ApprovalMode.AUTO_EDIT', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); const { result } = renderHook(() => @@ -141,43 +134,6 @@ describe('useAutoAcceptIndicator', () => { expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1); }); - it('should show AUTO entry notices on mount when initially in AUTO mode', () => { - mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO); - const mockAddItem = vi.fn(); - const mockSetValue = vi.fn(); - const mockSettings = { - merged: { - ui: { - autoModeAcknowledged: false, - }, - }, - setValue: mockSetValue, - }; - - const { result } = renderHook(() => - useAutoAcceptIndicator({ - config: mockConfigInstance as unknown as ActualConfigType, - settings: mockSettings as never, - addItem: mockAddItem, - }), - ); - - expect(result.current).toBe(ApprovalMode.AUTO); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.INFO, - text: expect.stringContaining('Auto mode enabled.'), - }), - expect.any(Number), - ); - expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.User, - 'ui.autoModeAcknowledged', - true, - ); - expect(mockConfigInstance.getApprovalMode).toHaveBeenCalledTimes(1); - }); - it('should initialize with ApprovalMode.DEFAULT if config.getApprovalMode returns ApprovalMode.DEFAULT', () => { mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.DEFAULT); const { result } = renderHook(() => @@ -613,91 +569,4 @@ describe('useAutoAcceptIndicator', () => { value: originalPlatform, }); }); - - it('localizes AUTO entry notices and stripped allow-rule sources', async () => { - await setLanguageAsync('zh'); - mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); - const mockAddItem = vi.fn(); - const mockSettings = { - merged: { - ui: { - autoModeAcknowledged: true, - }, - }, - }; - const mockPermissionManager = { - getStrippedDangerousRules: vi.fn(() => ({ - persistent: [{ raw: 'Bash(npm run *)' }], - session: [{ raw: 'Bash(node *)' }], - })), - }; - const configWithPermissionManager = { - ...mockConfigInstance, - getPermissionManager: vi.fn(() => mockPermissionManager), - }; - - renderHook(() => - useAutoAcceptIndicator({ - config: configWithPermissionManager as unknown as ActualConfigType, - settings: mockSettings as never, - addItem: mockAddItem, - }), - ); - - act(() => { - capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); - }); - - const noticeText = mockAddItem.mock.calls.at(-1)?.[0].text as string; - expect(noticeText).toContain('自动模式已临时禁用以下允许规则'); - expect(noticeText).toContain('(它们会绕过分类器):'); - expect(noticeText).toContain('Bash(npm run *) (来自用户设置)'); - expect(noticeText).toContain('Bash(node *) (会话)'); - expect(noticeText).toContain('退出自动模式时将恢复这些规则。'); - expect(noticeText).not.toContain('Auto mode temporarily disabled'); - expect(noticeText).not.toContain('from user settings'); - expect(noticeText).not.toContain('These will be restored'); - }); - - it('persists the first-time AUTO notice when cycling into AUTO mode', () => { - mockConfigInstance.getApprovalMode.mockReturnValue(ApprovalMode.AUTO_EDIT); - const mockAddItem = vi.fn(); - const mockSetValue = vi.fn(); - const mockSettings = { - merged: { - ui: { - autoModeAcknowledged: false, - }, - }, - setValue: mockSetValue, - }; - - renderHook(() => - useAutoAcceptIndicator({ - config: mockConfigInstance as unknown as ActualConfigType, - settings: mockSettings as never, - addItem: mockAddItem, - }), - ); - - act(() => { - capturedUseKeypressHandler({ name: 'tab', shift: true } as Key); - }); - - expect(mockConfigInstance.setApprovalMode).toHaveBeenCalledWith( - ApprovalMode.AUTO, - ); - expect(mockAddItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.INFO, - text: expect.stringContaining('Auto mode enabled.'), - }), - expect.any(Number), - ); - expect(mockSetValue).toHaveBeenCalledWith( - SettingScope.User, - 'ui.autoModeAcknowledged', - true, - ); - }); }); diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts index e7f85a8d710..00596888b9f 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts @@ -14,22 +14,13 @@ import { useKeypress } from './useKeypress.js'; import type { HistoryItemWithoutId } from '../types.js'; import { MessageType } from '../types.js'; import { type LoadedSettings, SettingScope } from '../../config/settings.js'; -import { t } from '../../i18n/index.js'; - -const getAutoModeFirstTimeMessage = (): string => - [ - `✨ ${t('Auto mode enabled.')}`, - ` ${t( - 'An LLM classifier evaluates each tool call and auto-approves safe actions,', - )}`, - ` ${t( - 'blocks risky ones. Most read-only operations and in-cwd edits skip the', - )}`, - ` ${t( - 'classifier for speed. To exit: Shift+Tab or /approval-mode default.', - )}`, - ` ${t('(This notice will not appear again.)')}`, - ].join('\n'); + +const AUTO_MODE_FIRST_TIME_MESSAGE = + '✨ Auto mode enabled.\n' + + ' An LLM classifier evaluates each tool call and auto-approves safe actions,\n' + + ' blocks risky ones. Most read-only operations and in-cwd edits skip the\n' + + ' classifier for speed. To exit: Shift+Tab or /approval-mode default.\n' + + ' (This notice will not appear again.)'; export interface UseAutoAcceptIndicatorArgs { config: Config; @@ -156,7 +147,7 @@ export function emitAutoModeEntryNotices(opts: { const acknowledged = settings?.merged.ui?.autoModeAcknowledged === true; if (!acknowledged) { addItem( - { type: MessageType.INFO, text: getAutoModeFirstTimeMessage() }, + { type: MessageType.INFO, text: AUTO_MODE_FIRST_TIME_MESSAGE }, now, ); if (settings) { @@ -177,13 +168,11 @@ export function emitAutoModeEntryNotices(opts: { (stripped.persistent.length > 0 || stripped.session.length > 0) ) { const lines = [ - `ℹ️ ${t('Auto mode temporarily disabled these allow rules')}`, - ` ${t('(they would bypass the classifier):')}`, - ...stripped.persistent.map( - (r) => ` - ${r.raw} ${t('(from user settings)')}`, - ), - ...stripped.session.map((r) => ` - ${r.raw} ${t('(session)')}`), - ` ${t('These will be restored when leaving auto mode.')}`, + 'ℹ️ Auto mode temporarily disabled these allow rules', + ' (they would bypass the classifier):', + ...stripped.persistent.map((r) => ` - ${r.raw} (from user settings)`), + ...stripped.session.map((r) => ` - ${r.raw} (session)`), + ' These will be restored when leaving auto mode.', ]; addItem({ type: MessageType.INFO, text: lines.join('\n') }, now + 1); }