From bb482e24260550dde95605c7dd9ce213bc699b95 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:20:28 +0900 Subject: [PATCH 1/4] fix(usage): retain readable aggregates with incomplete diagnostics --- .../content/docs/fr/guides/web-dashboard.md | 2 + .../content/docs/fr/reference/cli/agents.md | 2 + .../docs/fr/reference/management-api.md | 2 + .../src/content/docs/guides/web-dashboard.md | 5 + .../content/docs/ja/guides/web-dashboard.md | 2 + .../content/docs/ja/reference/cli/agents.md | 2 + .../docs/ja/reference/management-api.md | 2 + .../content/docs/ko/guides/web-dashboard.md | 2 + .../content/docs/ko/reference/cli/agents.md | 2 + .../docs/ko/reference/management-api.md | 2 + .../src/content/docs/reference/cli/agents.md | 4 + .../content/docs/reference/management-api.md | 12 +- .../content/docs/ru/guides/web-dashboard.md | 2 + .../content/docs/ru/reference/cli/agents.md | 2 + .../docs/ru/reference/management-api.md | 2 + .../content/docs/tr/guides/web-dashboard.md | 2 + .../content/docs/tr/reference/cli/agents.md | 2 + .../docs/tr/reference/management-api.md | 2 + .../docs/zh-cn/guides/web-dashboard.md | 2 + .../docs/zh-cn/reference/cli/agents.md | 2 + .../docs/zh-cn/reference/management-api.md | 2 + .../docs/zh-tw/guides/web-dashboard.md | 2 + .../docs/zh-tw/reference/cli/agents.md | 2 + .../docs/zh-tw/reference/management-api.md | 2 + gui/src/components/AddProviderModal.tsx | 6 +- .../apikeys-workspace/ApiKeysListPanel.tsx | 7 +- .../apikeys-workspace/ApiKeysWorkspace.tsx | 12 +- .../ProviderWorkspaceShell.tsx | 11 +- .../components/usage-incomplete-notice.tsx | 10 ++ gui/src/i18n/de.ts | 3 + gui/src/i18n/en.ts | 3 + gui/src/i18n/fr.ts | 3 + gui/src/i18n/ja.ts | 3 + gui/src/i18n/ko.ts | 3 + gui/src/i18n/ru.ts | 3 + gui/src/i18n/tr.ts | 3 + gui/src/i18n/zh-TW.ts | 3 + gui/src/i18n/zh.ts | 3 + gui/src/pages/ApiKeys.tsx | 7 +- gui/src/pages/Models.tsx | 3 +- gui/src/pages/Usage.tsx | 5 +- gui/src/pages/dashboard-overview-head.tsx | 2 + gui/src/pages/dashboard-shared.ts | 2 +- gui/src/usage-summary-resource.ts | 15 +++ gui/tests/apikeys-workspace.test.tsx | 20 +++ gui/tests/model-picker-order-editor.test.tsx | 36 ++++++ gui/tests/usage-custom-range.test.tsx | 19 +++ gui/tests/usage-incomplete-consumers.test.tsx | 116 ++++++++++++++++++ src/cli/usage-report.ts | 9 +- src/server/management/api-key-usage.ts | 9 +- src/server/management/logs-usage-routes.ts | 2 + src/server/management/oauth-account-routes.ts | 3 +- .../management/usage-aggregate-cache.ts | 29 +++-- src/server/management/usage-summary-cache.ts | 2 + structure/gui-and-management-api.md | 17 ++- tests/cli/cli-usage-report.test.ts | 29 +++++ tests/server/api-key-attribution.test.ts | 16 ++- tests/server/api-usage.test.ts | 19 ++- tests/usage/usage-aggregate-cache.test.ts | 39 +++++- 59 files changed, 485 insertions(+), 50 deletions(-) create mode 100644 gui/src/components/usage-incomplete-notice.tsx create mode 100644 gui/tests/usage-incomplete-consumers.test.tsx diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 2e333e39d8..1db1077a79 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -57,6 +57,8 @@ gestionnaire de mots de passe. | **Stockage** | Consultez en lecture seule la répartition du disque de CODEX_HOME — sessions, archives, bases de données et pièces jointes. Pour le nettoyage facultatif des archives, prévisualisez les N % les plus anciennes, puis placez-les en quarantaine dans `CODEX_HOME/.trash` (par défaut) ou supprimez-les définitivement après avoir coché une case explicite. **La stratégie de nettoyage automatique** est facultative et **désactivée par défaut** (`storageCleanupPolicy.enabled`) ; configurez son seuil, sa cible, sa planification et son mode sur la page **Stockage**, ou lancez **Exécuter maintenant**. Les entrées mises en quarantaine peuvent être restaurées depuis cette page (JSONL et fils). Les sessions actives restent en lecture seule. Le nettoyage et la restauration sont refusés tant que Codex verrouille le fichier `state_*.sqlite` le plus récent ou actif. | | **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). Sur Windows avec le backend Planificateur de tâches, le tableau de bord refuse et vous demande d'exécuter `ocx stop` : le wrapper peut relancer le proxy après la fin de la tâche, et seul un stop exécuté hors du proxy peut vérifier cette fenêtre de redémarrage avant de restaurer votre configuration client. Rien n'est modifié en cas de refus. | +Les vues Utilisation, Tableau de bord, Fournisseurs, Catalogue des fournisseurs et Clés API signalent les enregistrements exclus, même sans résultat lisible. Les comptes, dates et classements reposent uniquement sur les lignes lisibles. L’enregistrement de l’ordre des modèles par utilisation est refusé si l’historique est incomplet : choisissez un autre ordre ou réparez l’historique avant de réessayer. + ### Filtrer les requêtes Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous. diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 749119f70a..2a3e57e505 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -98,6 +98,8 @@ Inspectez les requêtes de proxy, l’utilisation, le stockage, la mémoire et l ocx observe usage --range 30d --json ``` +Si certains enregistrements ne peuvent pas être inclus, la sortie lisible affiche un avertissement et conserve les totaux lisibles, même sans ligne ou correspondance de filtre. Des lignes ignorées peuvent contenir des correspondances. `--json` préserve le diagnostic `usageIncomplete` et sa raison. + ### `ocx debug ` Lisez ou modifiez les remplacements de débogage d'exécution via la gestion du proxy en cours d'exécution API. diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index 0732df6dca..c37a566a52 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -145,6 +145,8 @@ Voir [Combos](/fr/guides/combos/) pour les stratégies cibles, les temps de rech | `POST /api/storage/cleanup-policy/run` | Démarrer une exécution manuelle de la politique de nettoyage | 409 `already_running` ; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Point d'ancrage du flux de stratégie réservé aux tests | 404 `not_found` en cas d'indisponibilité | +Si une ligne dépasse la limite de taille du parseur, `GET /api/usage` et `GET /api/keys` conservent les agrégats lisibles et ajoutent `usageIncomplete: true` avec `usageIncompleteReason: "oversized_rows"` au niveau de la réponse. Ce diagnostic reste présent dans le cache et après les ajouts incrémentaux, même sans résultat ou correspondance ; une reconstruction le recalcule. Les identifiants de fournisseur, de modèle et de clé API ne sont pas raccourcis. L’absence du champ ne prouve pas la validité de toutes les lignes. Ce signal est distinct de `historyTruncated`, `entriesTruncated` et de la couverture de mesure des tokens. + Pour `GET /api/usage?range=30d&surface=codex`, `accounts` contient une ligne par libellé de pool Codex observé. Chaque ligne indique `accountLogLabel`, le total de jetons, `usageCoverageRatio` et une valeur facultative `estimatedCostUsd` calculée selon les tarifs d'affichage actuellement configurés. Les substitutions `modelCosts` actives de l'utilisateur diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 7e86901233..2161f81358 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -96,6 +96,11 @@ badge or the version value to read the full value. | **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. | | **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. | +If some usage records cannot be included, the Usage page, Dashboard, provider workspace, provider +catalog, and API key views show a warning even when no readable records remain. Counts, dates, and +usage rankings reflect readable records only. **Models → Most used snapshot → Apply order** refuses +to save an incomplete snapshot; choose another order or repair the history before retrying. + ### Account selection Account selection is shared with request routing. Selecting an OAuth account takes effect on the diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 310baa4a5a..304c9646b8 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。隔離エントリは Storage ページから復元可能(JSONL + スレッド)。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中はクリーンアップと復元を拒否。 | | **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。ただし Windows のタスク スケジューラ バックエンドではダッシュボードが拒否し、`ocx stop` の実行を促します。タスク終了後もラッパーがプロキシを再起動しうるため、クライアント設定を戻す前にその再起動区間を確認できるのはプロキシの外で動く stop だけです。拒否されたときは何も変更されません。 | +使用量、ダッシュボード、プロバイダー画面、プロバイダーカタログ、API キー画面は、読み取れる記録がなくても除外された使用履歴の警告を表示します。回数、日付、使用順位は読み取れる記録のみを反映します。履歴が不完全な場合はモデルの使用回数順の保存を拒否します。別の順序を選ぶか、履歴を修復してから再試行してください。 + ### リクエストログの絞り込み Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。 diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index a223362a56..f05e266b45 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -69,6 +69,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +一部の使用履歴を集計できない場合、人向けの出力は読み取れる集計値を維持しながら警告を表示します。行がない場合やフィルターに一致しない場合も同様で、除外した行に一致する記録が含まれる可能性があります。`--json` は応答の `usageIncomplete` 診断と理由をそのまま保持します。 + ### `ocx debug ` 実行中のプロキシの管理 API を通じて、ランタイム デバッグ オーバーライドを読み取りまたは変更します。 diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index fecada7216..29e66259c6 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -130,6 +130,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` |手動クリーンアップ ポリシーの実行を開始します。 409 `already_running`; 500`cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` |テスト専用ポリシー ストリーム フック | 404 `not_found` 利用できない場合 | +行が既存のパーサーのサイズ上限を超えた場合、`GET /api/usage` と `GET /api/keys` は読み取れる行の集計を維持し、応答全体に `usageIncomplete: true` と `usageIncompleteReason: "oversized_rows"` を追加します。この診断はキャッシュや増分追記後も維持され、結果が空または一致なしでも返されます。再構築時には再計算されます。プロバイダー、モデル、API キーの識別子は短縮しません。フラグがないことは全行が有効だった証明にはなりません。`historyTruncated`、`entriesTruncated`、トークン測定カバレッジとは別の情報です。 + `models`、`providers`、および `days[].models` の各行にも `cacheHitRate` が含まれます。これは、プロバイダーのプロンプト キャッシュから供給された入力トークンの割合で、`[0, 1]` の範囲に制限されます。プロバイダーがキャッシュ テレメトリを報告しなかった場合、または行に入力トークンがない場合は、`0` ではなく `null` になります。「キャッシュ データなし」と「実際のヒット率 0%」は異なる事実であり、それらを同じように描画するチャートは誤解を招くためです。 :::caution diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index efdd80179f..783a5a19f0 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. Storage 페이지에서 격리 항목을 복원할 수 있습니다(JSONL + 스레드). 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 정리와 복원을 거절합니다. | | **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). 단, Windows 작업 스케줄러로 관리되는 경우에는 대시보드가 거절하고 `ocx stop`을 안내합니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 클라이언트 설정을 되돌리기 전에 그 재시작 구간을 확인할 수 있는 건 프록시 바깥에서 도는 stop뿐입니다. 거절될 때는 아무것도 바뀌지 않습니다. | +Usage, Dashboard, 공급자 작업 화면·카탈로그, API 키 화면은 읽을 수 있는 기록이 없어도 일부 기록 제외 경고를 표시합니다. 횟수·날짜·사용 순위는 읽을 수 있는 기록만 반영합니다. 이력이 불완전하면 모델의 ‘많이 사용한 순서’ 저장을 거절합니다. 다른 순서를 선택하거나 이력을 복구한 뒤 다시 시도하세요. + ### 요청 로그 필터 Logs에서는 클라이언트 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간, diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index 76cef4bda7..af001de6e7 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -94,6 +94,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +일부 사용량 기록을 집계하지 못하면 일반 출력은 읽을 수 있는 합계를 유지하며 경고합니다. 행이 없거나 필터에 일치하는 결과가 없어도 경고하며, 제외된 행에는 일치하는 기록이 있을 수 있습니다. `--json`은 응답의 `usageIncomplete` 진단과 사유를 그대로 유지합니다. + ### `ocx debug ` 실행 중인 프록시의 관리 API를 통해 런타임 디버그 override를 읽거나 변경합니다. diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index 086dc5aa49..3fbaf116f9 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -130,6 +130,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` | 수동 cleanup-policy 실행을 시작합니다 | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 테스트 전용 policy stream 훅입니다 | 사용할 수 없으면 404 `not_found` | +행이 기존 파서의 크기 제한을 넘으면 `GET /api/usage`와 `GET /api/keys`는 읽을 수 있는 행의 집계를 유지하고 응답 전체에 `usageIncomplete: true`, `usageIncompleteReason: "oversized_rows"`를 추가합니다. 이 진단은 캐시와 증분 추가에서도 유지되며, 빈 결과나 필터 일치 결과가 없는 경우에도 반환됩니다. 재구축 시에는 다시 계산합니다. 행을 맞추기 위해 공급자·모델·API 키 식별자를 줄이지 않습니다. 플래그가 없다고 모든 기록이 유효했다는 뜻은 아닙니다. `historyTruncated`, `entriesTruncated`, 토큰 측정 커버리지와는 별개입니다. + `models`, `providers`, `days[].models`의 행에도 `cacheHitRate`가 포함됩니다. 이 값은 공급자의 프롬프트 캐시에서 제공된 입력 토큰의 비율이며 `[0, 1]` 범위로 제한됩니다. 공급자가 캐시 텔레메트리를 보고하지 않았거나 행에 입력 토큰이 없으면 `0`이 아니라 항상 `null`입니다. "캐시 데이터 없음"과 "실제 적중률 0%"는 서로 다른 사실이며, diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index ab96881d11..a7b60c51e5 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -162,6 +162,10 @@ separately, and requests with no matching price row are counted as ocx usage --range today --provider xai ``` +When some usage records cannot be included, human output warns while retaining readable totals. +The warning also appears for zero rows or an unmatched filter; skipped records may contain matches. +`--json` preserves the response-level `usageIncomplete` diagnostic and reason. + ### `ocx debug ` Read or change runtime debug overrides through the running proxy's management API. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index b7fdbf072f..6a8bd6b83e 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -182,7 +182,7 @@ by the current window size. | `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — | | `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — | | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | -| `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | +| `GET /api/usage` | Scan the usage ledger into compact aggregates of readable rows, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | @@ -193,6 +193,14 @@ by the current window size. | `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable | +If a scanned row exceeds the existing parser size limit, `GET /api/usage` and `GET /api/keys` +keep the readable-row aggregates and add `usageIncomplete: true` with +`usageIncompleteReason: "oversized_rows"` at response level. This diagnostic survives cached +responses and incremental appends, including empty or unmatched results; a rebuild recalculates it. +No provider, model, or API-key identifier is shortened to make a row fit. An absent flag is not proof +that every ledger record was valid. This is separate from `historyTruncated`, `entriesTruncated`, +and token measurement coverage. + New xAI attempts in `usage.jsonl` include a request-time `credentialSource`: `grok-oauth` for the resolved Grok CLI OAuth transport, or `xai-api-key` for the public xAI API key transport. This fixed label contains no credential or account identifier. It belongs to @@ -205,7 +213,7 @@ The log reports usage, not subscription invoice amounts. snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather than every normalized request row. Later refreshes validate the previous line boundary and fold only newly appended complete rows. Concurrent callers share the same refresh. Range and surface predicates -are applied to the complete aggregate, so the former read-byte window and parsed-row cap cannot omit +are applied to the readable-row aggregate, so the former read-byte window and parsed-row cap cannot omit an earlier file prefix from 7-day, 30-day, or all-history totals. `managementUsageMaxReadBytes` remains accepted for compatibility with bounded legacy readers, but changing it no longer expands or reduces the history summarized by this endpoint. diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index 4600b51ace..a40ab67ec4 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -48,6 +48,8 @@ bun run dev:gui | **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Записи карантина можно восстановить со страницы Storage (JSONL + threads). Активные сессии только для чтения. Очистка и восстановление отклоняются, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. | | **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). На Windows с бэкендом планировщика заданий дашборд отказывает и просит выполнить `ocx stop`: обёртка может перезапустить прокси после завершения задачи, и проверить это окно перезапуска до восстановления клиентской конфигурации способен только stop, работающий вне прокси. При отказе ничего не изменяется. | +Страницы использования, дашборда, провайдеров, каталога провайдеров и API-ключей предупреждают об исключённых записях, даже если читаемых строк нет. Счётчики, даты и рейтинги основаны только на читаемых записях. Сохранение порядка моделей по частоте использования отклоняется при неполной истории: выберите другой порядок или восстановите историю перед повтором. + ### Фильтрация запросов Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи. diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 8df8175173..7e6343984b 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -79,6 +79,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +Если часть записей нельзя учесть, человекочитаемый вывод сохраняет доступные итоги и показывает предупреждение, даже без строк или совпадений фильтра. Пропущенные строки могут содержать совпадения. `--json` сохраняет диагностику `usageIncomplete` и её причину из ответа. + ### `ocx debug ` Прочитать или изменить runtime debug-override'ы через management API работающего прокси. diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index aefc4e09cf..5d3ad07c19 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -146,6 +146,8 @@ GUI-сессия в стиле loopback не выпускается. | `POST /api/storage/cleanup-policy/run` | Запустить manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Тестовый policy-stream hook | 404 `not_found`, когда недоступен | +Если строка превышает существующий лимит размера парсера, `GET /api/usage` и `GET /api/keys` сохраняют агрегаты читаемых строк и добавляют в ответ `usageIncomplete: true` и `usageIncompleteReason: "oversized_rows"`. Диагностика сохраняется в кеше и при инкрементальных добавлениях, в том числе для пустых результатов и отсутствующих совпадений; при перестроении она вычисляется заново. Идентификаторы провайдеров, моделей и API-ключей не сокращаются. Отсутствие флага не доказывает корректность всех строк. Это отдельный сигнал от `historyTruncated`, `entriesTruncated` и покрытия измерений токенов. + Строки в `models`, `providers` и `days[].models` также содержат `cacheHitRate` — долю входных токенов, полученных из кэша промптов провайдера и ограниченную диапазоном `[0, 1]`. Значение равно `null`, а не `0`, если провайдер не передал телеметрию кэша или в строке нет входных токенов: отсутствие diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md index ebdf946ffd..cfc04ca1df 100644 --- a/docs-site/src/content/docs/tr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md @@ -60,6 +60,8 @@ kararıdır. | **Depolama** | Salt okunur CODEX_HOME disk dökümü (oturumlar, arşivler, DB'ler, ekler). İsteğe bağlı arşivlenmiş temizleme: en eski %N'yi önizleyin, ardından `CODEX_HOME/.trash` konumuna karantinaya alın (varsayılan) veya açık bir onay kutusu arkasında kalıcı olarak silin. **Otomatik temizleme politikası** isteğe bağlıdır ve **varsayılan olarak KAPALIDIR** (`storageCleanupPolicy.enabled`); Depolama sayfasında eşik/hedef/zamanlama/mod yapılandırın veya **Şimdi çalıştır (Run now)**'ı tetikleyin. Karantinaya alınan girdiler Depolama sayfasından geri yüklenebilir (JSONL + iş parçacıkları). Aktif oturumlar salt okunur kalır. Codex en yeni/aktif `state_*.sqlite` dosyasını kilitli tuttuğu sürece temizleme ve geri yükleme reddedilir. | | **Durdur** | Proxy'yi ve kurulu arka plan servisini zarif bir şekilde durdurun, yerel Codex'i geri yükleyin ve çıkın (`POST /api/stop`). Windows'ta Görev Zamanlayıcı arka ucunda panel reddeder ve `ocx stop` çalıştırmanızı ister: görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir ve bu yeniden başlatma penceresini istemci yapılandırmanız geri yüklenmeden önce yalnızca proxy dışında çalışan bir stop doğrulayabilir. Reddedildiğinde hiçbir şey değiştirilmez. | +Kullanım, panel, sağlayıcı çalışma alanı, sağlayıcı kataloğu ve API anahtarı görünümleri, okunabilir kayıt kalmasa bile dışlanan kayıtlar için uyarı gösterir. Sayılar, tarihler ve kullanım sıralamaları yalnızca okunabilir kayıtlara dayanır. Geçmiş eksikse en çok kullanılan model sırası kaydedilmez; başka bir sıra seçin veya yeniden denemeden önce geçmişi onarın. + ### İstek günlüklerini filtreleme Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner. diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 04e72a766c..2d91057009 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -111,6 +111,8 @@ verilerini inceleyin. Doğrudan takma adlar şunlardır: ocx observe usage --range 30d --json ``` +Bazı kullanım kayıtları dahil edilemiyorsa okunabilir çıktı mevcut toplamları koruyarak uyarı gösterir. Satır veya filtre eşleşmesi olmadığında da uyarı görünür; atlanan satırlar eşleşme içerebilir. `--json`, yanıttaki `usageIncomplete` tanısını ve nedenini korur. + ### `ocx debug ` Çalışan proxy'nin yönetim API'si aracılığıyla çalışma zamanı hata ayıklama diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md index afdb835e9c..63846b5afb 100644 --- a/docs-site/src/content/docs/tr/reference/management-api.md +++ b/docs-site/src/content/docs/tr/reference/management-api.md @@ -152,6 +152,8 @@ Hedef stratejileri, soğuma süreleri, takma adlar ve yönlendirme hataları iç | `POST /api/storage/cleanup-policy/run` | Manuel bir temizleme politikası çalıştırması başlatın | 409 `already_running`; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Yalnızca test amaçlı politika akış kancası | Kullanılamadığında 404 `not_found` | +Bir satır mevcut ayrıştırıcı boyut sınırını aşarsa `GET /api/usage` ve `GET /api/keys` okunabilir satır toplamlarını korur ve yanıt düzeyinde `usageIncomplete: true` ile `usageIncompleteReason: "oversized_rows"` ekler. Bu tanı, boş veya eşleşmeyen sonuçlar dahil önbellekte ve artımlı eklemelerde korunur; yeniden oluşturma sırasında tekrar hesaplanır. Sağlayıcı, model ve API anahtarı kimlikleri kısaltılmaz. Bayrağın bulunmaması tüm kayıtların geçerli olduğunu kanıtlamaz. Bu bilgi `historyTruncated`, `entriesTruncated` ve token ölçüm kapsamından ayrıdır. + `GET /api/usage?range=30d&surface=codex` için `accounts`, gözlemlenen her Codex havuz etiketi için bir satır içerir. Her satır `accountLogLabel`, belirteç toplamları, `usageCoverageRatio` ve geçerli olarak yapılandırılmış görüntüleme diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index dbb6383120..6f99e516c1 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -47,6 +47,8 @@ bun run dev:gui | **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。可在 Storage 页从隔离区恢复(JSONL + 线程)。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理与恢复。 | | **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。在使用任务计划程序后端的 Windows 上,仪表板会拒绝并提示改用 `ocx stop`:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口。被拒绝时不会做任何更改。 | +用量、仪表板、供应商工作区、供应商目录和 API 密钥页面会提示部分记录被排除,即使没有可读取的记录。次数、日期和使用排名仅反映可读取的记录。历史不完整时,无法保存模型的最常用排序;请选择其他排序或修复历史后重试。 + ### 筛选请求日志 Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 89203420e9..a42bdb964c 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -75,6 +75,8 @@ API key,且绝不会回退到 native alias。启用这组兼容选项前,请 ocx observe usage --range 30d --json ``` +部分用量记录无法计入时,人类可读输出会保留可读取的总数并显示警告。没有记录或筛选匹配时也会警告,因为被跳过的记录可能包含匹配项。`--json` 原样保留响应中的 `usageIncomplete` 诊断及原因。 + ### `ocx debug ` 通过正在运行的代理的管理 API 读取或更改运行时调试覆盖项。 diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 9391afb5fd..8d2ea7eaba 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -130,6 +130,8 @@ Authorization: Bearer | `POST /api/storage/cleanup-policy/run` | 启动一次手动清理策略运行 | 409 `already_running`;500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 仅测试用的策略流钩子 | 不可用时返回 404 `not_found` | +如果某行超过现有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 会保留可读取行的汇总,并在响应级别添加 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。缓存和增量追加会保留该诊断,即使结果为空或没有筛选匹配;重建时会重新计算。不会缩短供应商、模型或 API 密钥标识来容纳该行。没有此标记不代表所有记录均有效。它与 `historyTruncated`、`entriesTruncated` 及 token 测量覆盖率相互独立。 + `models`、`providers` 和 `days[].models` 中的记录也带有 `cacheHitRate`:它表示由提供方提示缓存提供的输入 token 比例,并限制在 `[0, 1]` 范围内。当提供方未报告缓存遥测数据或该记录没有输入 token 时,其值为 `null`,绝不会是 `0`,因为“没有缓存数据”与“实际命中率为 0%”是不同的事实,将两者显示为相同结果的图表会产生误导。 :::caution diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md index 358624f2f9..29b324830f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md @@ -51,6 +51,8 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默 | **Usage / Debug** | 檢視 token usage 覆蓋率與趨勢,或啟用可選的 provider transport 和 usage 提取診斷。 | | **Stop** | 優雅地停止代理和已安裝的後臺服務,恢復原生 Codex 並退出(`POST /api/stop`)。在使用工作排程器後端的 Windows 上,儀表板會拒絕並提示改用 `ocx stop`:工作結束後包裝程序仍可能重新啟動 Proxy,只有執行在 Proxy 之外的 stop 才能在還原用戶端設定前確認這個重啟視窗。被拒絕時不會做任何變更。 | +用量、儀表板、供應商工作區、供應商目錄和 API 金鑰頁面會提示部分記錄被排除,即使沒有可讀取的記錄。次數、日期和使用排名僅反映可讀取的記錄。歷史不完整時,無法儲存模型的最常用排序;請選擇其他排序或修復歷史後重試。 + ### 篩選請求日誌 Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index d04c099ebf..7acfe61bdd 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -70,6 +70,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` +部分用量記錄無法納入時,人類可讀輸出會保留可讀取的總數並顯示警告。沒有記錄或篩選符合項目時也會警告,因為被略過的記錄可能包含符合項目。`--json` 原樣保留回應中的 `usageIncomplete` 診斷及原因。 + ### `ocx debug ` 透過執行中代理的管理 API 讀取或變更執行階段除錯覆寫。 diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md index ca899bc730..b10c38ab2c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/management-api.md +++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md @@ -130,6 +130,8 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠 | `POST /api/storage/cleanup-policy/run` | 啟動手動清理政策執行 | 409 `already_running`;500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | 僅測試的政策串流 hook | 不可用時 404 `not_found` | +如果某行超過現有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 會保留可讀取行的彙總,並在回應層級加入 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。快取和增量附加會保留此診斷,即使結果為空或沒有篩選符合項目;重建時會重新計算。不會縮短供應商、模型或 API 金鑰識別碼來容納該行。沒有此標記不代表所有記錄均有效。它與 `historyTruncated`、`entriesTruncated` 及 token 測量覆蓋率相互獨立。 + `models`、`providers` 及 `days[].models` 中的列也帶有 `cacheHitRate`:表示由供應商提示快取提供的輸入權杖比例,並限制在 `[0, 1]`。當供應商未回報快取遙測資料,或該列沒有輸入權杖時,其值為 `null`,絕不會是 `0`;因為「沒有快取資料」與「確實為 0% 的命中率」是不同事實,若圖表將兩者呈現為相同狀態,便會造成誤導。 :::caution diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx index 09f4fcb1d6..3ff9c631ca 100644 --- a/gui/src/components/AddProviderModal.tsx +++ b/gui/src/components/AddProviderModal.tsx @@ -1,4 +1,5 @@ -import { usageSummary30dResourceKey } from "../usage-summary-resource"; +import { usageSummary30dResourceKey, type UsageReadMetadata } from "../usage-summary-resource"; +import { UsageIncompleteNotice } from "./usage-incomplete-notice"; import { useEffect, useMemo, useReducer, useRef } from "react"; import { IconX } from "../icons"; import { useT } from "../i18n/shared"; @@ -89,7 +90,7 @@ export default function AddProviderModal({ async (signal) => { const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); if (!res.ok) throw new Error(String(res.status)); - return await res.json() as { providers?: Array<{ provider: string; requests: number }> }; + return await res.json() as UsageReadMetadata & { providers?: Array<{ provider: string; requests: number }> }; }, { deadlineMs: 60_000 }, // shared usage-summary key: all four subscribers raise the deadline together ); @@ -250,6 +251,7 @@ export default function AddProviderModal({ + {!preset && } {!preset ? ( + {keysLoading ? (
) : keys.length === 0 ? ( @@ -83,7 +88,7 @@ export default function ApiKeysListPanel({ ? "—" : k.usage.lastUsedAt ? formatCreatedDate(k.usage.lastUsedAt, localeTag) - : t("api.attribution.neverUsed")} + : t(usageMetadata?.usageIncomplete ? "api.attribution.noRecordedUse" : "api.attribution.neverUsed")} ))} diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx index dba0e23591..5b670428e3 100644 --- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx +++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx @@ -24,6 +24,8 @@ import { } from "../../pages/api-keys-panels"; import ClientConfigPanel from "./ClientConfigPanel"; import ApiKeysListPanel from "./ApiKeysListPanel"; +import type { UsageReadMetadata } from "../../usage-summary-resource"; +import { UsageIncompleteNotice } from "../usage-incomplete-notice"; export interface ApiKeysWorkspaceProps { keys: ApiKeyEntry[]; @@ -33,6 +35,7 @@ export interface ApiKeysWorkspaceProps { * statement from a key whose counters read zero. */ attributionSince?: string; historyTruncated?: boolean; + usageMetadata?: UsageReadMetadata; authMatrix: ApiAuthMatrixRow[]; keysLoading: boolean; keysLoadFailed: boolean; @@ -80,6 +83,7 @@ export default function ApiKeysWorkspace({ apiBase, attributionSince, historyTruncated, + usageMetadata, authMatrix, keysLoading, keysLoadFailed, @@ -397,6 +401,7 @@ export default function ApiKeysWorkspace({

{t("api.attribution.title")}

+ {/* Branch on the DATASET field, not on `usage`: a key with zero requests under a live dataset really was used zero times, which is not the same as having nothing to attribute. */} @@ -411,17 +416,17 @@ export default function ApiKeysWorkspace({
{selected.usage.requests7d.toLocaleString(localeTag)}
-
{historyTruncated ? t("api.attribution.totalRequestsAvailable") : t("api.attribution.totalRequests")}
+
{historyTruncated || usageMetadata?.usageIncomplete ? t("api.attribution.totalRequestsAvailable") : t("api.attribution.totalRequests")}
{selected.usage.totalRequests.toLocaleString(localeTag)}
{t("api.attribution.lastUsed")}
{selected.usage.lastUsedAt ? formatCreatedDate(selected.usage.lastUsedAt, localeTag) - : t("api.attribution.neverUsed")}
+ : t(usageMetadata?.usageIncomplete ? "api.attribution.noRecordedUse" : "api.attribution.neverUsed")}
-
{historyTruncated ? t("api.attribution.sinceAvailable") : t("api.attribution.since")}
+
{historyTruncated || usageMetadata?.usageIncomplete ? t("api.attribution.sinceAvailable") : t("api.attribution.since")}
{formatCreatedDate(attributionSince, localeTag)}
@@ -465,6 +470,7 @@ export default function ApiKeysWorkspace({ keysLoading={keysLoading} keysLoadFailed={keysLoadFailed} attributionSince={attributionSince} + usageMetadata={usageMetadata} localeTag={localeTag} busy={mutationPending} onSelect={id => { diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 73f0aad616..e478656baa 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -7,7 +7,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useKeyedClientResource } from "../../client-resource"; import { createBoundedFetch } from "../../bounded-fetch"; -import { usageSummary30dResourceKey } from "../../usage-summary-resource"; +import { readUsageMetadata, usageSummary30dResourceKey, type UsageReadMetadata } from "../../usage-summary-resource"; +import { UsageIncompleteNotice } from "../usage-incomplete-notice"; import { useT } from "../../i18n/shared"; import { IconFilter, IconSearch, IconBoxes, IconGlobe, IconLock, IconKey, IconTrash } from "../../icons"; import { @@ -150,6 +151,9 @@ export default function ProviderWorkspaceShell({ const [modelsLoadFailed, setModelsLoadFailed] = useState(false); const quotasCacheKey = `ocx.providers.quotas.v1:${apiBase}`; const usageCacheKey = `ocx.providers.usage.v2:${apiBase}`; + const [usageMetadata, setUsageMetadata] = useState(() => ( + readUsageMetadata(readSessionListCache(usageCacheKey)) + )); const [usageTotals, setUsageTotals] = useState>(() => ( readSessionListCache<{ totals: Record }>(usageCacheKey)?.totals ?? {} )); @@ -235,7 +239,9 @@ export default function ProviderWorkspaceShell({ setUsageTotals(byProvider); const byProviderModels = buildProviderModelUsage(data.models ?? [], byProvider); setUsageModels(byProviderModels); - writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels }); + const metadata = readUsageMetadata(data); + setUsageMetadata(metadata); + writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels, ...metadata }); setUsageLoading(false); }, 0); return () => { cancelled = true; window.clearTimeout(timeout); }; @@ -561,6 +567,7 @@ export default function ProviderWorkspaceShell({
+ {!jsonEditor?.open && } {jsonEditor?.open ? ( {t("usage.incomplete")} + : null; +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 68363152b5..8848d6db02 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,9 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Zähler, Daten und Rangfolgen beruhen nur auf lesbaren Datensätzen.", + "models.pickerOrder.usageIncomplete": "Die Reihenfolge nach Nutzung kann wegen unvollständiger Nutzungsdaten nicht gespeichert werden. Wählen Sie eine andere Reihenfolge oder reparieren Sie zuerst den Verlauf.", + "api.attribution.noRecordedUse": "Keine Nutzung in lesbaren Datensätzen", "models.pickerOrder.label": "Modellreihenfolge", "models.pickerOrder.default": "Standard", "models.pickerOrder.alphabetical": "A–Z nach Modell", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 89fc5ec0d7..cf9e04c742 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,9 @@ * `{var}` are plain interpolations. */ export const en = { + "usage.incomplete": "Some usage records could not be included. Counts, dates, and rankings reflect readable records only.", + "models.pickerOrder.usageIncomplete": "Cannot save most-used order because usage history is incomplete. Choose another order or repair the history first.", + "api.attribution.noRecordedUse": "No use in readable records", "models.pickerOrder.label": "Picker order", "models.pickerOrder.default": "Default", "models.pickerOrder.alphabetical": "A–Z by model", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4cf6818072..383113b06a 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les comptes, dates et classements reposent uniquement sur les enregistrements lisibles.", + "models.pickerOrder.usageIncomplete": "Impossible d’enregistrer l’ordre par utilisation : l’historique est incomplet. Choisissez un autre ordre ou réparez d’abord l’historique.", + "api.attribution.noRecordedUse": "Aucune utilisation dans les enregistrements lisibles", "models.pickerOrder.label": "Ordre des modèles", "models.pickerOrder.default": "Par défaut", "models.pickerOrder.alphabetical": "A–Z par modèle", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c77edbe2c7..343bc35575 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "usage.incomplete": "一部の使用履歴を集計できませんでした。回数、日付、順位は読み取れる記録のみを反映しています。", + "models.pickerOrder.usageIncomplete": "使用履歴が不完全なため、使用回数順を保存できません。別の順序を選ぶか、履歴を修復してください。", + "api.attribution.noRecordedUse": "読み取れる記録に使用履歴なし", "models.pickerOrder.label": "モデル選択順", "models.pickerOrder.default": "デフォルト", "models.pickerOrder.alphabetical": "モデル名のA–Z順", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 34d5ceae87..f80446554a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "usage.incomplete": "일부 사용량 기록을 집계하지 못했습니다. 횟수, 날짜, 순위는 읽을 수 있는 기록만 반영합니다.", + "models.pickerOrder.usageIncomplete": "사용량 이력이 불완전해 많이 사용한 순서를 저장할 수 없습니다. 다른 순서를 선택하거나 이력을 복구하세요.", + "api.attribution.noRecordedUse": "읽을 수 있는 기록에 사용 내역 없음", "models.pickerOrder.label": "모델 선택 순서", "models.pickerOrder.default": "기본값", "models.pickerOrder.alphabetical": "모델 이름순", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 488f87d55b..f9eeb4cdcb 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "usage.incomplete": "Часть записей об использовании не удалось учесть. Счётчики, даты и рейтинги основаны только на читаемых записях.", + "models.pickerOrder.usageIncomplete": "Нельзя сохранить порядок по частоте использования: история неполная. Выберите другой порядок или сначала восстановите историю.", + "api.attribution.noRecordedUse": "В читаемых записях использование не найдено", "models.pickerOrder.label": "Порядок моделей", "models.pickerOrder.default": "По умолчанию", "models.pickerOrder.alphabetical": "По имени A–Z", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2e46e2792f..367a927361 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,9 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "usage.incomplete": "Bazı kullanım kayıtları dahil edilemedi. Sayılar, tarihler ve sıralamalar yalnızca okunabilir kayıtlara dayanır.", + "models.pickerOrder.usageIncomplete": "Kullanım geçmişi eksik olduğundan en çok kullanılan sıralaması kaydedilemiyor. Başka bir sıralama seçin veya önce geçmişi onarın.", + "api.attribution.noRecordedUse": "Okunabilir kayıtlarda kullanım yok", "models.pickerOrder.label": "Model sırası", "models.pickerOrder.default": "Varsayılan", "models.pickerOrder.alphabetical": "Model adına göre A–Z", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b9a26da41c..615dfae441 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,9 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "usage.incomplete": "部分用量記錄無法納入。次數、日期和排名僅反映可讀取的記錄。", + "models.pickerOrder.usageIncomplete": "用量歷史不完整,無法儲存最常用排序。請選擇其他排序或先修復歷史記錄。", + "api.attribution.noRecordedUse": "可讀取的記錄中沒有使用記錄", "models.pickerOrder.label": "模型選擇順序", "models.pickerOrder.default": "預設", "models.pickerOrder.alphabetical": "依模型名稱 A–Z", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 46866680b0..494b68becb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,9 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "usage.incomplete": "部分用量记录无法计入。次数、日期和排名仅反映可读取的记录。", + "models.pickerOrder.usageIncomplete": "用量历史不完整,无法保存最常用排序。请选择其他排序或先修复历史记录。", + "api.attribution.noRecordedUse": "可读取的记录中没有使用记录", "models.pickerOrder.label": "模型选择顺序", "models.pickerOrder.default": "默认", "models.pickerOrder.alphabetical": "按模型名 A–Z", diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx index d10ff3c33d..ccfedc11ce 100644 --- a/gui/src/pages/ApiKeys.tsx +++ b/gui/src/pages/ApiKeys.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import { Notice } from "../ui"; +import { readUsageMetadata, type UsageReadMetadata } from "../usage-summary-resource"; import { useI18n, LOCALES } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; @@ -27,7 +28,7 @@ import { type ModelTests, } from "./api-keys-utils"; -interface KeysResponse { +interface KeysResponse extends UsageReadMetadata { // `usage` is optional on the wire only so a malformed payload lands in // fetchKeys' validator rather than at the type boundary. A row without it is // rejected, not defaulted: zeroes would assert "never used" about data we @@ -53,7 +54,7 @@ interface StartRotationResponse extends CreateKeyResponse { rotationId?: unknown; } -type CachedKeysShape = { +type CachedKeysShape = UsageReadMetadata & { keys: ApiKeyEntry[]; endpoints: ApiEndpointInfo; claudeCodeEnabled: boolean; @@ -159,6 +160,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a claudeCodeEnabled: data.claudeCodeEnabled !== false, ...(data.attributionSince ? { attributionSince: data.attributionSince } : {}), ...(data.historyTruncated === true ? { historyTruncated: true } : {}), + ...readUsageMetadata(data), authMatrix: data.authMatrix, }; // Prefixes only — never the secret key material. @@ -500,6 +502,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a apiBase={apiBase} attributionSince={attributionSince} historyTruncated={historyTruncated} + usageMetadata={readUsageMetadata(keysData)} authMatrix={authMatrix} keysLoading={false} keysLoadFailed={keysState.showError} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 0150cd29b0..3a4fb03db6 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1920,8 +1920,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; if (mode === "most-used") { const response = await fetch(`${apiBase}/api/usage?range=all&surface=all`, { signal: bounded.signal }); if (!current()) return; - const payload = await readJsonOrThrow<{ models?: unknown }>(response, t("models.pickerOrder.usageFailed")); + const payload = await readJsonOrThrow<{ models?: unknown; usageIncomplete?: unknown }>(response, t("models.pickerOrder.usageFailed")); if (!current()) return; + if (payload?.usageIncomplete === true) throw new Error(t("models.pickerOrder.usageIncomplete")); if (!isModelPickerUsage(payload?.models)) throw new Error(t("models.pickerOrder.usageFailed")); usage = payload.models; } diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 96f0f1db0c..900ab6eac0 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; +import type { UsageReadMetadata } from "../usage-summary-resource"; +import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; @@ -79,7 +81,7 @@ interface UsageProvider { class UsageWindowMismatchError extends Error {} -interface UsageResponse { +interface UsageResponse extends UsageReadMetadata { range: Range; surface: UsageSurface; since: number | null; @@ -989,6 +991,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas ) : ( <> {state.showError && {t(connected ? "usage.hubOffline" : "usage.loadError")}} + {data?.historyTruncated && ( // Naming the loaded window is the point: without it, `30d` and "Available history" // look identical on a busy installation even though both may cover far less than diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 44b3f8ac3c..c956ca1f4b 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -4,6 +4,7 @@ import { formatTokens } from "../format-tokens"; import { formatUptime } from "../formatUptime"; import { navigateHash } from "../hash-routing"; import type { useDashboardData } from "./use-dashboard-data"; +import { UsageIncompleteNotice } from "../components/usage-incomplete-notice"; type Dash = ReturnType; @@ -113,6 +114,7 @@ export function DashboardOverviewHead({ + {projectConfigWarnings.length > 0 && (
diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index 029e39b2da..ae24f861a2 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -123,7 +123,7 @@ export interface SidecarPatch { }; } export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] } -export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } +export type UsageSummary30d = import("../usage-summary-resource").UsageReadMetadata & { summary: { requests: number; totalTokens: number; coverageRatio: number } }; export type UpdateChannel = "latest" | "preview"; export type Installer = "npm" | "bun" | "source"; export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed"; diff --git a/gui/src/usage-summary-resource.ts b/gui/src/usage-summary-resource.ts index 568860230c..e427258194 100644 --- a/gui/src/usage-summary-resource.ts +++ b/gui/src/usage-summary-resource.ts @@ -1,3 +1,18 @@ +/** Positive diagnostics only: an older response without the flag proves no completeness. */ +export interface UsageReadMetadata { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; +} + +export function readUsageMetadata(value: unknown): UsageReadMetadata { + if (!value || typeof value !== "object" || !("usageIncomplete" in value) || value.usageIncomplete !== true) return {}; + return { + usageIncomplete: true, + ...("usageIncompleteReason" in value && value.usageIncompleteReason === "oversized_rows" + ? { usageIncompleteReason: "oversized_rows" as const } : {}), + }; +} + export function usageSummary30dResourceKey(apiBase: string, surface: "all" | "codex" = "all"): string { return surface === "codex" ? ["usage-summary-30d", apiBase, "codex"].join(":") diff --git a/gui/tests/apikeys-workspace.test.tsx b/gui/tests/apikeys-workspace.test.tsx index 56ffeacaf1..4695e09430 100644 --- a/gui/tests/apikeys-workspace.test.tsx +++ b/gui/tests/apikeys-workspace.test.tsx @@ -135,6 +135,26 @@ function keyButton(container: HTMLElement, name: string): HTMLButtonElement { .find(el => el.textContent === name)!; } +test("incomplete usage qualifies key list and detail without asserting never used", async () => { + const { root, container, rerender } = await mountWorkspace({ + usageMetadata: { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }, + }); + try { + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("No use in readable records"); + await act(async () => { keyButton(container, "beta").click(); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("Requests in available history"); + expect(container.textContent).toContain("No use in readable records"); + await rerender({ attributionSince: undefined }); + expect(container.textContent).toContain("Some usage records could not be included"); + await rerender({ keys: [] }); + expect(container.textContent).toContain("Some usage records could not be included"); + await rerender({ usageMetadata: {} }); + expect(container.textContent).not.toContain("Some usage records could not be included"); + } finally { await act(async () => { root.unmount(); }); } +}); + test("workspace overview navigation preserves pending secret and resets delete confirm", async () => { const { root, container } = await mountWorkspace({ newKey: FULL_SECRET, diff --git a/gui/tests/model-picker-order-editor.test.tsx b/gui/tests/model-picker-order-editor.test.tsx index ea9f808cb7..0789264470 100644 --- a/gui/tests/model-picker-order-editor.test.tsx +++ b/gui/tests/model-picker-order-editor.test.tsx @@ -388,3 +388,39 @@ test("Models pins cache-inferred Custom across late parent GET publication, then await act(async () => { root!.render(); }); expect(host.querySelector(".picker-order-editor")).toBeNull(); }); + +test("Models refuses an incomplete most-used snapshot before PUT and accepts a later readable snapshot", async () => { + const modelRows = ids.map(row => ({ ...row, disabled: false })); + const catalog = { models: modelRows, providers: [{ name: "p" }], selectedModels: {}, disabled: [], contextCaps: {}, contextCapValue: 350_000 }; + const settings = { ...initial(), pickerOrderMode: "most-used" }; + win.sessionStorage.setItem("ocx.models.catalog.v1:/a", JSON.stringify(catalog)); + win.sessionStorage.setItem("ocx.models.catalog.v1:/a:picker-order", JSON.stringify(settings)); + const deferredFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.includes("/api/usage?") || init?.method === "PUT") return deferredFetch(input, init); + const payload = path.endsWith("/api/subagent-models") ? settings + : path.endsWith("/api/models") ? modelRows + : path.endsWith("/api/providers") ? catalog.providers + : path.endsWith("/api/provider-context-caps") ? { caps: {} } + : path.endsWith("/api/selected-models") ? { selected: {} } + : path.endsWith("/api/aliases") ? { providers: {}, models: {}, defaults: { global: false, providers: {} } } + : undefined; + return Promise.resolve(payload === undefined ? new Response(null, { status: 404 }) : Response.json(payload)); + } }); + const { createRoot } = await import("react-dom/client"); + await act(async () => { root = createRoot(host); root.render(); }); + await click("Apply order"); + expect(requests[0]?.url).toBe("/a/api/usage?range=all&surface=all"); + const models = [{ provider: "p", model: "b", requests: 3 }]; + await reply(0, { models, usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + expect(host.textContent).toContain("Cannot save most-used order because usage history is incomplete"); + expect(requests.map(r => r.method)).toEqual(["GET"]); + expect(button("Apply order").disabled).toBe(false); + await click("Apply order"); + await reply(1, { models }); + expect(requests[2]?.url).toBe("/a/api/subagent-models"); + expect(requests[2]?.method).toBe("PUT"); + expect(requests[2]?.body).toEqual({ pickerOrder: ["p/b", "p/a", "p/c", "p/f"], pickerOrderMode: "most-used" }); + await reply(2, { ok: true, pickerOrder: ["p/b", "p/a", "p/c", "p/f"], pickerOrderMode: "most-used" }); +}); diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index ea4e98e51c..78a3e9b165 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -85,6 +85,25 @@ async function respond(index: number, marker: string, date?: string) { await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); }); } +test("incomplete usage notice survives held cache and remains visible with no readable rows", async () => { + await mount(); + const partial = { ...report(requests[0], "readable-model"), usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; + await act(async () => { requests[0].resolve(Response.json(partial)); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).toContain("readable-model"); + expect(sessionEntries().some(([, value]) => value?.includes('"usageIncomplete":true'))).toBe(true); + await act(async () => { root!.unmount(); }); + root = undefined; + clearClientResourceStoresForTests(); + await mount(); + expect(container.textContent).toContain("Some usage records could not be included"); + await act(async () => { requests[1].resolve(Response.json({ ...partial, + summary: { ...partial.summary, requests: 0, totalTokens: 0 }, days: [], models: [], + })); }); + expect(container.textContent).toContain("Some usage records could not be included"); + expect(container.textContent).not.toContain("readable-model"); +}); + const toggle = () => container.querySelector(".usage-range-toggle")!; const form = () => container.querySelector('form[aria-label="Custom date range"]')!; const startInput = () => form().querySelectorAll('input[type="datetime-local"]')[0]; diff --git a/gui/tests/usage-incomplete-consumers.test.tsx b/gui/tests/usage-incomplete-consumers.test.tsx new file mode 100644 index 0000000000..3d538d6cc9 --- /dev/null +++ b/gui/tests/usage-incomplete-consumers.test.tsx @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { readSessionListCache } from "../src/session-list-cache"; +import { readUsageMetadata } from "../src/usage-summary-resource"; +import { DashboardOverviewHead } from "../src/pages/dashboard-overview-head"; +import ProviderWorkspaceShell from "../src/components/provider-workspace/ProviderWorkspaceShell"; +import AddProviderModal from "../src/components/AddProviderModal"; +import ApiKeys from "../src/pages/ApiKeys"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previous: Map; +let win: Window, host: HTMLElement, root: Root | null; +let usageBody: Record, keysBody: Record; +let hold = false; +const partial = { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; +const warning = "Some usage records could not be included"; + +beforeEach(() => { + clearClientResourceStoresForTests(); + previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + win = new Window({ url: "http://localhost/" }); + win.localStorage.setItem("ocx-lang", "en"); + const values = { document: win.document, window: win, navigator: win.navigator, + localStorage: win.localStorage, sessionStorage: win.sessionStorage, IS_REACT_ACT_ENVIRONMENT: true }; + for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, value }); + root = null; hold = false; + usageBody = { ...partial, providers: [], models: [] }; + keysBody = { ...partial, keys: [], authMatrix: [{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }] }; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => { + if (hold) return new Promise((_resolve, reject) => { + if (init?.signal?.aborted) reject(new Error("aborted")); + else init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + const path = String(input); + const body = path.includes("/api/usage?") ? usageBody + : path.endsWith("/api/keys") ? keysBody + : path.endsWith("/api/models") ? [] + : path.endsWith("/v1/models") ? { data: [] } + : path.endsWith("/api/selected-models") ? { selected: {}, available: {}, liveModelCounts: {} } + : path.includes("/api/provider-quotas") ? { reports: [] } + : path.endsWith("/api/oauth/providers") ? { providers: [] } + : path.endsWith("/api/provider-presets") ? { providers: [{ id: "test", label: "Test", adapter: "openai-chat", baseUrl: "https://example.test", auth: "key" }] } + : {}; + return Response.json(body); + } }); + host = document.createElement("div"); document.body.append(host); +}); + +afterEach(async () => { + if (root) await act(async () => { root!.unmount(); }); + clearClientResourceStoresForTests(); + win.close(); + for (const key of globals) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function mount(node: ReactNode) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { root ??= createRoot(host); root.render({node}); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 25)); }); +} +async function remountFromCache(node: ReactNode) { + await act(async () => { root!.unmount(); }); root = null; + clearClientResourceStoresForTests(); hold = true; + await mount(node); +} + +test("metadata reader preserves positive diagnostics without inferring completeness or copying fields", () => { + for (const value of [null, {}, { usageIncomplete: false }, { usageIncomplete: "true" }]) expect(readUsageMetadata(value)).toEqual({}); + expect(readUsageMetadata({ ...partial, models: [1], token: "private" })).toEqual(partial); + expect(readUsageMetadata({ usageIncomplete: true, usageIncompleteReason: "future_reason" })).toEqual({ usageIncomplete: true }); +}); + +test("Dashboard warns even when no readable requests remain", async () => { + await mount( {}} switchMaMode={async () => {}} maError={null} />); + expect(host.textContent).toContain(warning); +}); + +test("provider usage projection retains incomplete metadata through a cache-only revisit", async () => { + usageBody = { ...partial, providers: [{ provider: "test", requests: 7, totalTokens: 123 }], models: [] }; + const node = {}} onAddProvider={() => {}} />; + await mount(node); + expect(host.textContent).toContain(warning); + const cached = readSessionListCache>("ocx.providers.usage.v2:/provider"); + expect(cached).toMatchObject({ ...partial, totals: { test: { requests: 7, totalTokens: 123 } } }); + await remountFromCache(node); + expect(host.textContent).toContain(warning); +}); + +test("provider catalog explains that its usage ranking can be incomplete without any readable rows", async () => { + await mount( {}} onAdded={() => {}} />); + expect(host.textContent).toContain(warning); +}); + +test("API key fetch and session cache retain incomplete metadata even without attribution or keys", async () => { + const node = ; + await mount(node); + expect(host.textContent).toContain(warning); + const cached = readSessionListCache>("ocx.apikeys.list.v2:/keys"); + expect(cached).toMatchObject({ ...partial, keys: [] }); + expect(cached).not.toHaveProperty("attributionSince"); + await remountFromCache(node); + expect(host.textContent).toContain(warning); +}); diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts index 3311a781a1..d9c6652e23 100644 --- a/src/cli/usage-report.ts +++ b/src/cli/usage-report.ts @@ -21,6 +21,8 @@ interface CostRow { } interface UsageReportInput { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; range?: string; surface?: string; since?: number | null; @@ -109,11 +111,16 @@ function describeScope(data: UsageReportInput): string { export function formatUsageReport(data: UsageReportInput): string[] { const summary = data.summary ?? {}; const lines: string[] = [describeScope(data), ""]; + if (data.usageIncomplete === true) { + lines.push("WARNING: Usage is incomplete; some records could not be included. Totals and rankings reflect readable records only.", ""); + } if (data.filter && !data.filter.matched) { const what = [data.filter.provider && `provider "${data.filter.provider}"`, data.filter.model && `model "${data.filter.model}"`] .filter(Boolean).join(" and "); - lines.push(`No usage recorded for ${terminalText(what)} in this range.`); + lines.push(data.usageIncomplete === true + ? `No matching readable usage records for ${terminalText(what)} in this range; skipped records may contain matches.` + : `No usage recorded for ${terminalText(what)} in this range.`); lines.push("Check the spelling against `ocx usage --json`, or widen --range."); return lines.map(terminalText); } diff --git a/src/server/management/api-key-usage.ts b/src/server/management/api-key-usage.ts index 6a8664dee2..7314ac2a20 100644 --- a/src/server/management/api-key-usage.ts +++ b/src/server/management/api-key-usage.ts @@ -19,6 +19,9 @@ export type ApiKeyUsage = export interface ApiKeyUsageSnapshot { rollup: Map; historyTruncated?: true; + /** Positive evidence of skipped oversized rows; absence is not a completeness guarantee. */ + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; /** * Earliest row carrying a recognized `admissionKind`. A property of the DATA * SET, not of a key, so it is singular and lives beside the map: it is what @@ -223,9 +226,11 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte const flight = (async (): Promise => { const accumulator = createApiKeyUsageAccumulator(configuredIds, now); const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); - if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); return cacheApiKeyUsageFromRollup( - accumulator.snapshot(), + { + ...accumulator.snapshot(), + ...(scan.oversizedRows > 0 ? { usageIncomplete: true as const, usageIncompleteReason: "oversized_rows" as const } : {}), + }, configuredIds, usageLogIdentityKey(scan.revision), scan.revision?.size ?? 0, diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 72a019d0a2..cd1e3ed3b0 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -225,6 +225,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise k.id), config.managementUsageMaxReadBytes); + const { rollup, attributionSince, historyTruncated, usageIncomplete, usageIncompleteReason } = await readApiKeyUsageRollup(keys.map(k => k.id), config.managementUsageMaxReadBytes); return jsonResponse({ // 8 random hex past the fixed `ocx_data_` literal: enough to tell two keys // apart in a list, with 128 bits of the tail still unrevealed. Masking only @@ -736,6 +736,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // Dataset-level and singular: it describes the usage log, not any one key. ...(attributionSince ? { attributionSince } : {}), ...(historyTruncated ? { historyTruncated: true } : {}), + ...(usageIncomplete ? { usageIncomplete: true, usageIncompleteReason } : {}), authMatrix: AUTH_MATRIX, ...endpoints, }, 200, req, config); diff --git a/src/server/management/usage-aggregate-cache.ts b/src/server/management/usage-aggregate-cache.ts index 51e02781f1..1cabf9b85b 100644 --- a/src/server/management/usage-aggregate-cache.ts +++ b/src/server/management/usage-aggregate-cache.ts @@ -23,6 +23,7 @@ import { interface RetainedUsageAggregate { accumulator: UsageSummaryAccumulator; + usageIncomplete: boolean; revision: UsageLogRevision | null; identityKey: string; revisionKey: string; @@ -35,6 +36,7 @@ interface RetainedUsageAggregate { export interface UsageAggregateResult { accumulator: UsageSummaryAccumulator; + usageIncomplete: boolean; revision: UsageLogRevision | null; processedThroughBytes: number; overlayVersion: number; @@ -75,6 +77,7 @@ function resultFrom( ): UsageAggregateResult { return { accumulator: state.accumulator, + usageIncomplete: state.usageIncomplete, revision: state.revision, processedThroughBytes: state.processedThroughBytes, overlayVersion: state.overlayVersion, @@ -99,6 +102,7 @@ function makeRetainedAggregate( ): RetainedUsageAggregate { return { accumulator, + usageIncomplete: scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), @@ -126,9 +130,6 @@ async function rebuildAggregate(options: UsageAggregateOptions): Promise 0) { - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { lastError = new Error("usage aggregation inputs changed during rebuild"); continue; @@ -138,7 +139,10 @@ async function rebuildAggregate(options: UsageAggregateOptions): Promise candidate.add(entry), }); - if (scan.oversizedRows > 0) { - if (retainedAggregate === state) retainedAggregate = null; - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { if (retainedAggregate === state) retainedAggregate = null; rebuildAfterUnpin = true; @@ -200,6 +200,9 @@ async function appendAggregate( const next: RetainedUsageAggregate = { ...state, accumulator: candidate, + // A partial unterminated row can be scanned again on the next append. + // Preserve a boolean diagnostic rather than double-counting omissions. + usageIncomplete: state.usageIncomplete || scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), @@ -329,7 +332,6 @@ async function rebuildFilteredAggregate( const accumulator = createUsageSummaryAccumulator({ filter, mode: "row-unique", window }); try { const scan = await scanUsageLedgerCooperatively({ onEntry: entry => accumulator.add(entry) }); - if (scan.oversizedRows > 0) throw new Error("usage ledger contains an oversized row"); if (userCostOverlayVersion() !== overlayVersion || currentTimeZone() !== timeZone) { lastError = new Error("usage aggregation inputs changed during filtered scan"); continue; @@ -362,10 +364,6 @@ async function appendFilteredAggregate( expectedProcessedThroughDigest: state.processedThroughDigest, onEntry: entry => candidate.add(entry), }); - if (scan.oversizedRows > 0) { - if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); - throw new Error("usage ledger contains an oversized row"); - } if (userCostOverlayVersion() !== state.overlayVersion || currentTimeZone() !== state.timeZone) { if (retainedFilteredAggregates.get(key) === state) retainedFilteredAggregates.delete(key); rebuildAfterUnpin = true; @@ -373,6 +371,7 @@ async function appendFilteredAggregate( const next: RetainedUsageAggregate = { ...state, accumulator: candidate, + usageIncomplete: state.usageIncomplete || scan.oversizedRows > 0, revision: scan.revision, identityKey: usageLogIdentityKey(scan.revision), revisionKey: usageLogRevisionKey(scan.revision), diff --git a/src/server/management/usage-summary-cache.ts b/src/server/management/usage-summary-cache.ts index 1e6815c0a2..80f35398a3 100644 --- a/src/server/management/usage-summary-cache.ts +++ b/src/server/management/usage-summary-cache.ts @@ -2,6 +2,8 @@ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../../l import type { UsageSummary } from "../../usage/summary"; export type CachedUsageSummary = UsageSummary & { + usageIncomplete?: true; + usageIncompleteReason?: "oversized_rows"; historyTruncated: boolean; truncatedPrefixBytes: number; entriesTruncated: boolean; diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c6b35fbad8..26128b8569 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -122,7 +122,7 @@ this document owns is which module holds which area and what invariant that area | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | -| Usage | `GET /api/usage` aggregate read-only summary derived from the complete `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | +| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; it does not widen a Remote Hub management ingress to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | | Diagnostics/sync | `src/server/management/config-routes.ts` — `GET /api/diagnostics/project-config` reports project-level Codex config that bypasses managed routing; `POST /api/sync` re-runs catalog/config sync. The diagnostic reports the bypass; it does not rewrite the project file. | @@ -401,7 +401,7 @@ An opt-in shadow-call rewrite persists the bounded, redacted original helper mod request content or inferring a helper subtype from timing. `src/usage/summary.ts` turns that file into the `/api/usage` shape — totals, daily zero-filled grid, model and provider breakdowns, and `measured / reported / unreported / unsupported / estimated` counts. -The management route streams the complete ledger from its beginning in fixed 1 MiB chunks on a +The management route scans the ledger from its beginning in fixed 1 MiB chunks on a cold rebuild, then retains compact numeric aggregate state and resumes at the last verified LF for ordinary appends. It does not retain the full input or a normalized object for every request, and neither the old byte window nor the parsed-entry cap can discard an earlier prefix before range and @@ -438,6 +438,19 @@ large existing log. The first read is proportional to ledger size; steady-state proportional to newly appended bytes. The Dashboard polls its 30-day usage summary independently once per minute, so usage work cannot delay health/provider/settings state or run every five seconds. +An oversized row is skipped within the existing scanner bound, without shortening provider, +model, or API-key identities. Base and filtered accumulators retain normal rows and a positive +`usageIncomplete` diagnostic. Append publication ORs the previous flag with the new scan; a rebuild +recalculates it. Summary-cache hits and direct or aggregate-seeded API-key rollups preserve the +response-level `usageIncomplete: true` / `usageIncompleteReason: "oversized_rows"` metadata, even +when no normal rows or attributed keys remain. Invalid-row counters are not a sticky diagnostic: +they also include temporarily torn suffixes. Absence of the flag is not a completeness guarantee. +The GUI preserves the metadata in held/session caches and warns in Usage, Dashboard, provider +workspace/catalog, and key list/detail views. Human CLI output warns before no-match early returns; +JSON remains unchanged. Saving a most-used model-order snapshot refuses an incomplete response. +No warning is attached to separate provider quota data. Legacy truncation fields and measurement +coverage keep their existing meanings; file-read/mutation failures still fail closed. + `usage.jsonl` is an append-only runtime ledger. A manual in-place edit earlier than the trailing 64 KiB checkpoint followed by file growth is intentionally outside the incremental detector's contract: validating arbitrary historical rewrites on every refresh would require rereading the diff --git a/tests/cli/cli-usage-report.test.ts b/tests/cli/cli-usage-report.test.ts index 5399137a57..0de6bc7950 100644 --- a/tests/cli/cli-usage-report.test.ts +++ b/tests/cli/cli-usage-report.test.ts @@ -84,6 +84,24 @@ describe("formatUsageReport", () => { expect(JSON.parse(out)).toEqual(malformed); }); + test("incomplete usage retains readable totals and warns even with no data or no match", () => { + const partial = { usageIncomplete: true, usageIncompleteReason: "oversized_rows" }; + const out = formatUsageReport(payload(partial) as never).join("\n"); + expect(out).toContain("WARNING: Usage is incomplete"); + expect(out).toContain("Requests 1,447"); + expect(out).toContain("grok-4.6"); + expect(out.indexOf("WARNING:")).toBeLessThan(out.indexOf("Requests")); + const empty = payload({ ...partial, summary: { requests: 0, totalTokens: 0 }, providers: [], models: [], days: [] }); + expect(formatUsageReport(empty as never).join("\n")).toContain("WARNING: Usage is incomplete"); + const noMatch = formatUsageReport({ ...empty, + filter: { provider: "nope", model: null, matched: false, comboOverlap: false }, + } as never).join("\n"); + expect(noMatch).toContain("WARNING: Usage is incomplete"); + expect(noMatch).toContain("skipped records may contain matches"); + expect(noMatch).not.toContain("No usage recorded"); + expect(formatUsageReport(payload() as never).join("\n")).not.toContain("WARNING: Usage is incomplete"); + }); + test("prints per-provider and per-model cost, not an item count", () => { const out = formatUsageReport(payload() as never).join("\n"); expect(out).toContain("~$12.3456"); @@ -168,6 +186,17 @@ describe("formatUsageReport", () => { }); describe("ocx usage command", () => { + test("incomplete usage succeeds with human warning and unchanged JSON metadata", async () => { + const body = payload({ usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + const human = await run(["usage"], body); + expect(human.code).toBe(0); + expect(human.out).toContain("WARNING: Usage is incomplete"); + expect(human.out).toContain("grok-4.6"); + const json = await run(["usage", "--json"], body); + expect(json.code).toBe(0); + expect(JSON.parse(json.out)).toEqual(body); + }); + test("duplicate, inline and stray custom-bound arguments do not echo credential-shaped values", async () => { const secret = "sk-" + "a".repeat(40); const errors: string[] = []; diff --git a/tests/server/api-key-attribution.test.ts b/tests/server/api-key-attribution.test.ts index 6be92c4652..469d98f485 100644 --- a/tests/server/api-key-attribution.test.ts +++ b/tests/server/api-key-attribution.test.ts @@ -399,7 +399,7 @@ describe("attribution reaches usage.jsonl", () => { } }); - test("an oversized usage row cannot seed a partial key rollup", async () => { + test.each(["keys-first", "usage-first"])("an oversized usage row preserves an explicitly incomplete key rollup: %s", async order => { saveConfig(remoteConfig()); const now = Date.now(); const oversized = { @@ -428,11 +428,21 @@ describe("attribution reaches usage.jsonl", () => { writeFileSync(usageLogPath(), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); const server = startServer(0); try { + if (order === "usage-first") { + const usage = await fetch(new URL("/api/usage?range=all", server.url), { + headers: { "x-opencodex-api-key": ADMIN_TOKEN }, + }).then(res => res.json()); + expect(usage).toMatchObject({ usageIncomplete: true }); + } const payload = await keysGet(server); const keys = payload.keys as Array>; expect((keys.find(key => key.id === "key-one")!.usage as Record).totalRequests).toBe(0); - expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(0); - expect(payload.attributionSince).toBeUndefined(); + expect((keys.find(key => key.id === "key-two")!.usage as Record).totalRequests).toBe(1); + expect(payload.attributionSince).toBe(new Date(now).toISOString()); + expect(payload).toMatchObject({ usageIncomplete: true, usageIncompleteReason: "oversized_rows" }); + expect(await keysGet(server)).toMatchObject({ + usageIncomplete: true, usageIncompleteReason: "oversized_rows", attributionSince: payload.attributionSince, + }); } finally { await server.stop(true); } diff --git a/tests/server/api-usage.test.ts b/tests/server/api-usage.test.ts index fa5c0ee2e2..9615c622ae 100644 --- a/tests/server/api-usage.test.ts +++ b/tests/server/api-usage.test.ts @@ -868,7 +868,7 @@ describe("GET /api/usage", () => { } }); - test("an oversized row fails closed instead of caching a partial aggregate", async () => { + test("an oversized row preserves normal usage with explicit incomplete cached and filtered results", async () => { const now = Date.now(); const oversized = { requestId: "ocx-oversized", @@ -896,11 +896,18 @@ describe("GET /api/usage", () => { writeFileSync(join(testDir, "usage.jsonl"), `${JSON.stringify(oversized)}\n${JSON.stringify(valid)}\n`); const server = startServer(0); try { - const body = await fetch(new URL("/api/usage?range=all", server.url)).then(res => res.json()); - expect(body.error).toBe("read_failed"); - expect(body.summary.requests).toBe(0); - expect(body.historyTruncated).toBe(false); - expect(getUsageSummaryCacheEntry("all:all")).toBeUndefined(); + for (const query of ["range=all", "range=all", "range=7d", "range=all&model=gpt-5.5"]) { + const response = await fetch(new URL(`/api/usage?${query}`, server.url)); + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.error).toBeUndefined(); + expect(body.summary.requests).toBe(1); + expect(body.summary.totalTokens).toBe(2); + expect(body).toMatchObject({ + historyTruncated: false, usageIncomplete: true, usageIncompleteReason: "oversized_rows", + }); + } + expect(getUsageSummaryCacheEntry("all:all")?.summary).toMatchObject({ usageIncomplete: true }); } finally { await server.stop(true); } diff --git a/tests/usage/usage-aggregate-cache.test.ts b/tests/usage/usage-aggregate-cache.test.ts index cdaea2c23b..c2a539bf94 100644 --- a/tests/usage/usage-aggregate-cache.test.ts +++ b/tests/usage/usage-aggregate-cache.test.ts @@ -129,6 +129,28 @@ describe("retained usage aggregate cache", () => { expect(report.summary.unmeteredRequests).toBe(1); }); + test.each(["base", "filtered"])("an oversized unfinished suffix stays incomplete without duplicating rows: %s", async scope => { + const path = join(testDir, "usage.jsonl"); + const read = () => scope === "filtered" ? getFilteredUsageAggregate({ provider: "openai" }) : getUsageAggregate({ now: NOW }); + writeFileSync(path, line("one")); + expect(requests(await read())).toBe(1); + appendFileSync(path, JSON.stringify({ + ...entry("oversized"), padding: "x".repeat(usageLedgerScannerModule.USAGE_LEDGER_MAX_LINE_BYTES), + })); + const unfinished = await read(); + expect(unfinished).toMatchObject({ usageIncomplete: true }); + expect(requests(unfinished)).toBe(1); + appendFileSync(path, "\n" + line("two")); + const completed = await read(); + expect(completed).toMatchObject({ usageIncomplete: true }); + expect(requests(completed)).toBe(2); + expect(await read()).toMatchObject({ update: "unchanged", usageIncomplete: true }); + writeFileSync(path, line("replacement")); + const rebuilt = await read(); + expect(rebuilt).toMatchObject({ update: "rebuild", usageIncomplete: false }); + expect(requests(rebuilt)).toBe(1); + }); + test("custom cache keys isolate both endpoints and never poison preset aggregates", async () => { const path = join(testDir, "usage.jsonl"); const rows = [NOW - 2_000, NOW - 1_000, NOW].map((timestamp, index) => ({ ...entry(String(index)), timestamp })); @@ -392,7 +414,7 @@ describe("retained usage aggregate cache", () => { } }); - test("an oversized append result never publishes its partially-fed candidate", async () => { + test("an oversized append retains normal rows and its incomplete marker until rebuild", async () => { writeFileSync(join(testDir, "usage.jsonl"), line("one")); const originalScan = usageLedgerScannerModule.scanUsageLedgerCooperatively; let forceOversizedAppend = false; @@ -412,14 +434,21 @@ describe("retained usage aggregate cache", () => { appendFileSync(join(testDir, "usage.jsonl"), line("two")); forceOversizedAppend = true; - await expect(getUsageAggregate({ now: NOW })).rejects.toThrow("oversized row"); + const partial = await getUsageAggregate({ now: NOW }); + expect(partial).toMatchObject({ update: "append", usageIncomplete: true }); + expect(requests(partial)).toBe(2); expect(requests(original)).toBe(1); - expect(usageAggregateRetainedStats().count).toBe(0); + expect(original).toMatchObject({ usageIncomplete: false }); + expect(usageAggregateRetainedStats().count).toBe(1); forceOversizedAppend = false; + const unchanged = await getUsageAggregate({ now: NOW }); + expect(unchanged).toMatchObject({ update: "unchanged", usageIncomplete: true }); + expect(requests(unchanged)).toBe(2); + writeFileSync(join(testDir, "usage.jsonl"), line("replaced")); const rebuilt = await getUsageAggregate({ now: NOW }); - expect(rebuilt.update).toBe("rebuild"); - expect(requests(rebuilt)).toBe(2); + expect(rebuilt).toMatchObject({ update: "rebuild", usageIncomplete: false }); + expect(requests(rebuilt)).toBe(1); expect(scanStarts).toHaveLength(3); expect(scanStarts[0]).toBe(0); expect(scanStarts[1]).toBeGreaterThan(0); From 6011a85e7cc677ef4fd5708a23d57f84ad75ec71 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:50:41 +0900 Subject: [PATCH 2/4] docs(usage): distinguish unmatched filters from readable totals --- docs-site/src/content/docs/fr/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ja/reference/cli/agents.md | 2 +- docs-site/src/content/docs/ko/reference/cli/agents.md | 2 +- docs-site/src/content/docs/reference/cli/agents.md | 5 +++-- docs-site/src/content/docs/ru/reference/cli/agents.md | 2 +- docs-site/src/content/docs/tr/reference/cli/agents.md | 2 +- docs-site/src/content/docs/zh-cn/reference/cli/agents.md | 2 +- docs-site/src/content/docs/zh-tw/reference/cli/agents.md | 2 +- 8 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index 2a3e57e505..0fc9a639d9 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -98,7 +98,7 @@ Inspectez les requêtes de proxy, l’utilisation, le stockage, la mémoire et l ocx observe usage --range 30d --json ``` -Si certains enregistrements ne peuvent pas être inclus, la sortie lisible affiche un avertissement et conserve les totaux lisibles, même sans ligne ou correspondance de filtre. Des lignes ignorées peuvent contenir des correspondances. `--json` préserve le diagnostic `usageIncomplete` et sa raison. +Si certains enregistrements ne peuvent pas être inclus, la sortie lisible affiche un avertissement, même sans ligne lisible. Les totaux affichés ne reflètent que les enregistrements lisibles. Si un filtre ne trouve aucune correspondance lisible, la sortie affiche l'avertissement et des indications au lieu des lignes de totaux ; les enregistrements ignorés peuvent contenir des correspondances. `--json` préserve le diagnostic `usageIncomplete` et sa raison. ### `ocx debug ` diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index f05e266b45..a9f4ab549b 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -69,7 +69,7 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` -一部の使用履歴を集計できない場合、人向けの出力は読み取れる集計値を維持しながら警告を表示します。行がない場合やフィルターに一致しない場合も同様で、除外した行に一致する記録が含まれる可能性があります。`--json` は応答の `usageIncomplete` 診断と理由をそのまま保持します。 +一部の使用履歴を集計できない場合、人向けの出力は読み取れる行がない場合も警告を表示します。表示される合計値は読み取れる記録のみを反映します。フィルターに一致する読み取れる記録がない場合は、合計欄の代わりに警告と案内を表示します。除外した記録には一致するものが含まれる可能性があります。`--json` は応答の `usageIncomplete` 診断と理由をそのまま保持します。 ### `ocx debug ` diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index af001de6e7..8391fb5bc7 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -94,7 +94,7 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` -일부 사용량 기록을 집계하지 못하면 일반 출력은 읽을 수 있는 합계를 유지하며 경고합니다. 행이 없거나 필터에 일치하는 결과가 없어도 경고하며, 제외된 행에는 일치하는 기록이 있을 수 있습니다. `--json`은 응답의 `usageIncomplete` 진단과 사유를 그대로 유지합니다. +일부 사용량 기록을 집계하지 못하면 일반 출력은 읽을 수 있는 행이 없어도 경고합니다. 표시되는 합계는 읽을 수 있는 기록만 반영합니다. 필터에 일치하는 읽을 수 있는 기록이 없으면 합계 항목 대신 경고와 안내를 표시하며, 제외된 기록에는 일치하는 항목이 있을 수 있습니다. `--json`은 응답의 `usageIncomplete` 진단과 사유를 그대로 유지합니다. ### `ocx debug ` diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index a7b60c51e5..eecf303f4b 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -162,8 +162,9 @@ separately, and requests with no matching price row are counted as ocx usage --range today --provider xai ``` -When some usage records cannot be included, human output warns while retaining readable totals. -The warning also appears for zero rows or an unmatched filter; skipped records may contain matches. +When some usage records cannot be included, human output warns, including when there are zero readable rows. +Any displayed totals reflect readable records only. If a filter has no readable matches, the output shows +the warning and guidance instead of total lines; skipped records may contain matches. `--json` preserves the response-level `usageIncomplete` diagnostic and reason. ### `ocx debug ` diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index 7e6343984b..3b9e21f6f4 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -79,7 +79,7 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` -Если часть записей нельзя учесть, человекочитаемый вывод сохраняет доступные итоги и показывает предупреждение, даже без строк или совпадений фильтра. Пропущенные строки могут содержать совпадения. `--json` сохраняет диагностику `usageIncomplete` и её причину из ответа. +Если часть записей нельзя учесть, человекочитаемый вывод показывает предупреждение, даже если нет читаемых строк. Отображаемые итоги учитывают только читаемые записи. Если фильтр не находит читаемых совпадений, вместо строк итогов выводятся предупреждение и подсказки; пропущенные записи могут содержать совпадения. `--json` сохраняет диагностику `usageIncomplete` и её причину из ответа. ### `ocx debug ` diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index 2d91057009..9b2741d4ba 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -111,7 +111,7 @@ verilerini inceleyin. Doğrudan takma adlar şunlardır: ocx observe usage --range 30d --json ``` -Bazı kullanım kayıtları dahil edilemiyorsa okunabilir çıktı mevcut toplamları koruyarak uyarı gösterir. Satır veya filtre eşleşmesi olmadığında da uyarı görünür; atlanan satırlar eşleşme içerebilir. `--json`, yanıttaki `usageIncomplete` tanısını ve nedenini korur. +Bazı kullanım kayıtları dahil edilemiyorsa okunabilir çıktı, okunabilir satır olmadığında da uyarı gösterir. Gösterilen toplamlar yalnızca okunabilir kayıtları yansıtır. Filtreyle eşleşen okunabilir kayıt yoksa toplam satırları yerine uyarı ve yönlendirme gösterilir; atlanan kayıtlar eşleşme içerebilir. `--json`, yanıttaki `usageIncomplete` tanısını ve nedenini korur. ### `ocx debug ` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index a42bdb964c..e525698f4f 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -75,7 +75,7 @@ API key,且绝不会回退到 native alias。启用这组兼容选项前,请 ocx observe usage --range 30d --json ``` -部分用量记录无法计入时,人类可读输出会保留可读取的总数并显示警告。没有记录或筛选匹配时也会警告,因为被跳过的记录可能包含匹配项。`--json` 原样保留响应中的 `usageIncomplete` 诊断及原因。 +部分用量记录无法计入时,人类可读输出会显示警告,即使没有可读取的记录也是如此。显示的总数仅反映可读取的记录。如果筛选条件没有匹配到可读取的记录,输出将显示警告和提示,而不显示总数行;被跳过的记录可能包含匹配项。`--json` 原样保留响应中的 `usageIncomplete` 诊断及原因。 ### `ocx debug ` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 7acfe61bdd..2d6b7cd49e 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -70,7 +70,7 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ocx observe usage --range 30d --json ``` -部分用量記錄無法納入時,人類可讀輸出會保留可讀取的總數並顯示警告。沒有記錄或篩選符合項目時也會警告,因為被略過的記錄可能包含符合項目。`--json` 原樣保留回應中的 `usageIncomplete` 診斷及原因。 +部分用量記錄無法納入時,人類可讀輸出會顯示警告,即使沒有可讀取的記錄也是如此。顯示的總數僅反映可讀取的記錄。如果篩選條件沒有符合的可讀取記錄,輸出將顯示警告和提示,而不顯示總數列;被略過的記錄可能包含符合項目。`--json` 原樣保留回應中的 `usageIncomplete` 診斷及原因。 ### `ocx debug ` From f851ed37f670c0d314c9450d59ef360f03b8ac44 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:36:56 +0900 Subject: [PATCH 3/4] docs(usage): correct French incomplete-history wording --- docs-site/src/content/docs/fr/guides/web-dashboard.md | 2 +- docs-site/src/content/docs/fr/reference/management-api.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md index 1db1077a79..2d4d753bb5 100644 --- a/docs-site/src/content/docs/fr/guides/web-dashboard.md +++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md @@ -57,7 +57,7 @@ gestionnaire de mots de passe. | **Stockage** | Consultez en lecture seule la répartition du disque de CODEX_HOME — sessions, archives, bases de données et pièces jointes. Pour le nettoyage facultatif des archives, prévisualisez les N % les plus anciennes, puis placez-les en quarantaine dans `CODEX_HOME/.trash` (par défaut) ou supprimez-les définitivement après avoir coché une case explicite. **La stratégie de nettoyage automatique** est facultative et **désactivée par défaut** (`storageCleanupPolicy.enabled`) ; configurez son seuil, sa cible, sa planification et son mode sur la page **Stockage**, ou lancez **Exécuter maintenant**. Les entrées mises en quarantaine peuvent être restaurées depuis cette page (JSONL et fils). Les sessions actives restent en lecture seule. Le nettoyage et la restauration sont refusés tant que Codex verrouille le fichier `state_*.sqlite` le plus récent ou actif. | | **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). Sur Windows avec le backend Planificateur de tâches, le tableau de bord refuse et vous demande d'exécuter `ocx stop` : le wrapper peut relancer le proxy après la fin de la tâche, et seul un stop exécuté hors du proxy peut vérifier cette fenêtre de redémarrage avant de restaurer votre configuration client. Rien n'est modifié en cas de refus. | -Les vues Utilisation, Tableau de bord, Fournisseurs, Catalogue des fournisseurs et Clés API signalent les enregistrements exclus, même sans résultat lisible. Les comptes, dates et classements reposent uniquement sur les lignes lisibles. L’enregistrement de l’ordre des modèles par utilisation est refusé si l’historique est incomplet : choisissez un autre ordre ou réparez l’historique avant de réessayer. +Les vues Utilisation, Tableau de bord, Fournisseurs, Catalogue des fournisseurs et Clés API signalent les enregistrements exclus, même sans résultat lisible. Les décomptes, les dates et les classements reposent uniquement sur les lignes lisibles. L’enregistrement de l’ordre des modèles par utilisation est refusé si l’historique est incomplet : choisissez un autre ordre ou réparez l’historique avant de réessayer. ### Filtrer les requêtes diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md index c37a566a52..db192889aa 100644 --- a/docs-site/src/content/docs/fr/reference/management-api.md +++ b/docs-site/src/content/docs/fr/reference/management-api.md @@ -145,7 +145,7 @@ Voir [Combos](/fr/guides/combos/) pour les stratégies cibles, les temps de rech | `POST /api/storage/cleanup-policy/run` | Démarrer une exécution manuelle de la politique de nettoyage | 409 `already_running` ; 500 `cleanup_failed` | | `GET /api/storage/cleanup-policy/test-stream` | Point d'ancrage du flux de stratégie réservé aux tests | 404 `not_found` en cas d'indisponibilité | -Si une ligne dépasse la limite de taille du parseur, `GET /api/usage` et `GET /api/keys` conservent les agrégats lisibles et ajoutent `usageIncomplete: true` avec `usageIncompleteReason: "oversized_rows"` au niveau de la réponse. Ce diagnostic reste présent dans le cache et après les ajouts incrémentaux, même sans résultat ou correspondance ; une reconstruction le recalcule. Les identifiants de fournisseur, de modèle et de clé API ne sont pas raccourcis. L’absence du champ ne prouve pas la validité de toutes les lignes. Ce signal est distinct de `historyTruncated`, `entriesTruncated` et de la couverture de mesure des tokens. +Si une ligne dépasse la limite de taille du parseur, `GET /api/usage` et `GET /api/keys` conservent les agrégats lisibles et ajoutent `usageIncomplete: true` avec `usageIncompleteReason: "oversized_rows"` au niveau de la réponse. Ce diagnostic reste présent dans le cache et après les ajouts incrémentaux, même sans résultat ni correspondance de filtre ; une reconstruction le recalcule. Les identifiants de fournisseur, de modèle et de clé API ne sont pas raccourcis. L’absence du champ ne prouve pas la validité de toutes les lignes. Ce signal est distinct de `historyTruncated`, `entriesTruncated` et de la couverture de mesure des tokens. Pour `GET /api/usage?range=30d&surface=codex`, `accounts` contient une ligne par libellé de pool Codex observé. Chaque ligne indique `accountLogLabel`, le total de jetons, `usageCoverageRatio` et une valeur facultative From 2f07acb58b3e73f48cea38334f301b430a8634cd Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:40:43 +0900 Subject: [PATCH 4/4] fix(gui): use precise French and German usage-warning terms CodeRabbit flagged that the incomplete-usage notice mistranslated the English categories. French 'comptes' reads as accounts rather than the numeric totals the notice describes, and German 'Daten'/'Rangfolgen' broadened dates into generic data and rankings into an ordering. --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/fr.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8848d6db02..da15fd41c4 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,7 +5,7 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { - "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Zähler, Daten und Rangfolgen beruhen nur auf lesbaren Datensätzen.", + "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Anzahlen, Datumsangaben und Ranglisten beruhen nur auf lesbaren Datensätzen.", "models.pickerOrder.usageIncomplete": "Die Reihenfolge nach Nutzung kann wegen unvollständiger Nutzungsdaten nicht gespeichert werden. Wählen Sie eine andere Reihenfolge oder reparieren Sie zuerst den Verlauf.", "api.attribution.noRecordedUse": "Keine Nutzung in lesbaren Datensätzen", "models.pickerOrder.label": "Modellreihenfolge", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 383113b06a..ef434c7285 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,7 +4,7 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { - "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les comptes, dates et classements reposent uniquement sur les enregistrements lisibles.", + "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les totaux, dates et classements reposent uniquement sur les enregistrements lisibles.", "models.pickerOrder.usageIncomplete": "Impossible d’enregistrer l’ordre par utilisation : l’historique est incomplet. Choisissez un autre ordre ou réparez d’abord l’historique.", "api.attribution.noRecordedUse": "Aucune utilisation dans les enregistrements lisibles", "models.pickerOrder.label": "Ordre des modèles",