diff --git a/.github/pr-assets/opencodex-cache-usage.png b/.github/pr-assets/opencodex-cache-usage.png new file mode 100644 index 00000000000..6011c89b57f Binary files /dev/null and b/.github/pr-assets/opencodex-cache-usage.png differ diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 246791f2843..1a718f6b8ef 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -93,7 +93,7 @@ badge or the version value to read the full value. | **Subagents** | Feature up to five bare native or namespaced routed models in the `spawn_agent` override list. | | **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose v1/base/v2, and configure the v2 thread limit. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. | | **Logs** | Auto-refresh recent requests with tokens, requested effort and (when available) effective outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact reasoning wire field when the adapter emits one. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | -| **Usage / Debug** | Inspect token-usage coverage and trends, or enable opt-in provider transport and usage-extraction diagnostics. | +| **Usage / Debug** | Inspect token-usage coverage and trends. The Usage page's Models table also breaks each model down into input tokens, output tokens, cache hits, cache writes, and cache hit rate; a dash means cache telemetry for that metric is unavailable. Or enable opt-in provider transport and usage-extraction diagnostics. | | **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. | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a98f0b1de0f..67646c1bba1 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -945,6 +945,12 @@ export const de: Record = { "usage.col.requests": "Anfragen", "usage.col.measured": "Gemessen", "usage.col.reported": "Gemeldet", + "usage.col.inputTokens": "Eingabe-Tokens", + "usage.col.outputTokens": "Ausgabe-Tokens", + "usage.col.cacheHits": "Cache-Treffer", + "usage.col.cacheWrites": "Cache-Schreibvorgänge", + "usage.col.cacheHitRate": "Trefferquote", + "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "API-Listenpreis", "usage.col.share": "Anteil", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index f738334aea8..dfd8b99c304 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -998,6 +998,12 @@ export const en = { "usage.col.requests": "Requests", "usage.col.measured": "Measured", "usage.col.reported": "Reported", + "usage.col.inputTokens": "Input tokens", + "usage.col.outputTokens": "Output tokens", + "usage.col.cacheHits": "Cache hits", + "usage.col.cacheWrites": "Cache writes", + "usage.col.cacheHitRate": "Hit rate", + "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "API list-price", "usage.col.share": "Share", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 6340b01a0a8..4b0340280c2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -975,6 +975,12 @@ export const fr: Record = { "usage.col.requests": "Requêtes", "usage.col.measured": "Mesurées", "usage.col.reported": "Communiquées", + "usage.col.inputTokens": "Jetons d’entrée", + "usage.col.outputTokens": "Jetons de sortie", + "usage.col.cacheHits": "Lectures du cache", + "usage.col.cacheWrites": "Écritures dans le cache", + "usage.col.cacheHitRate": "Taux de succès du cache", + "usage.unavailable": "—", "usage.col.tokens": "Jetons", "usage.col.apiListPrice": "Tarif catalogue API", "usage.col.share": "Part", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4989033e86d..abc46d31691 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -911,6 +911,12 @@ export const ja: Record = { "usage.col.requests": "リクエスト", "usage.col.measured": "計測", "usage.col.reported": "報告", + "usage.col.inputTokens": "入力トークン", + "usage.col.outputTokens": "出力トークン", + "usage.col.cacheHits": "キャッシュヒット", + "usage.col.cacheWrites": "キャッシュ書き込み", + "usage.col.cacheHitRate": "ヒット率", + "usage.unavailable": "—", "usage.col.tokens": "トークン", "usage.col.apiListPrice": "API 定価", "usage.col.share": "割合", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index e9e9bc08ade..29fc7cbb84f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -979,6 +979,12 @@ export const ko: Record = { "usage.col.requests": "요청", "usage.col.measured": "측정됨", "usage.col.reported": "측정됨", + "usage.col.inputTokens": "입력 토큰", + "usage.col.outputTokens": "출력 토큰", + "usage.col.cacheHits": "캐시 히트", + "usage.col.cacheWrites": "캐시 쓰기", + "usage.col.cacheHitRate": "히트율", + "usage.unavailable": "—", "usage.col.tokens": "토큰", "usage.col.apiListPrice": "API 정가", "usage.col.share": "비율", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 239784d355d..dd6628a8f1a 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -966,6 +966,12 @@ export const ru: Record = { "usage.col.requests": "Запросы", "usage.col.measured": "Измерено", "usage.col.reported": "Сообщено", + "usage.col.inputTokens": "Входные токены", + "usage.col.outputTokens": "Выходные токены", + "usage.col.cacheHits": "Попадания в кэш", + "usage.col.cacheWrites": "Записи в кэш", + "usage.col.cacheHitRate": "Доля попаданий", + "usage.unavailable": "—", "usage.col.tokens": "Токены", "usage.col.apiListPrice": "Прайс-лист API", "usage.col.share": "Доля", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 8dd4d98c1ae..ff60ea51be9 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -985,6 +985,12 @@ export const tr: Record = { "usage.col.requests": "İstekler", "usage.col.measured": "Ölçülen", "usage.col.reported": "Bildirilen", + "usage.col.inputTokens": "Girdi jetonları", + "usage.col.outputTokens": "Çıktı jetonları", + "usage.col.cacheHits": "Önbellek isabetleri", + "usage.col.cacheWrites": "Önbellek yazma", + "usage.col.cacheHitRate": "İsabet oranı", + "usage.unavailable": "—", "usage.col.tokens": "Jetonlar", "usage.col.apiListPrice": "API liste fiyatı", "usage.col.share": "Pay", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 580bc13afef..bf1707e3409 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -975,6 +975,12 @@ export const vi: Record = { "usage.col.requests": "Yêu cầu", "usage.col.measured": "Đã đo", "usage.col.reported": "Đã báo cáo", + "usage.col.inputTokens": "Token đầu vào", + "usage.col.outputTokens": "Token đầu ra", + "usage.col.cacheHits": "Lượt truy cập cache", + "usage.col.cacheWrites": "Lần ghi cache", + "usage.col.cacheHitRate": "Tỷ lệ truy cập cache", + "usage.unavailable": "—", "usage.col.tokens": "Tokens", "usage.col.apiListPrice": "Giá niêm yết API", "usage.col.share": "Tỷ trọng", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b53b4fc5361..c9666bbbefe 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -785,6 +785,12 @@ export const zhTW: Record = { "usage.col.requests": "請求數", "usage.col.measured": "已計量", "usage.col.reported": "已上報", + "usage.col.inputTokens": "輸入 Token", + "usage.col.outputTokens": "輸出 Token", + "usage.col.cacheHits": "快取命中", + "usage.col.cacheWrites": "快取寫入", + "usage.col.cacheHitRate": "命中率", + "usage.unavailable": "—", "usage.col.tokens": "Token 數", "usage.col.apiListPrice": "API 標價", "usage.col.share": "佔比", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 27c92df0d74..e09fcb5d031 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -960,6 +960,12 @@ export const zh: Record = { "usage.col.requests": "请求数", "usage.col.measured": "已计量", "usage.col.reported": "已上报", + "usage.col.inputTokens": "输入 Token", + "usage.col.outputTokens": "输出 Token", + "usage.col.cacheHits": "缓存命中", + "usage.col.cacheWrites": "缓存写入", + "usage.col.cacheHitRate": "命中率", + "usage.unavailable": "—", "usage.col.tokens": "Token 数", "usage.col.apiListPrice": "API 标价", "usage.col.share": "占比", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index de96b16e115..6c059b329b1 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -67,6 +67,12 @@ interface UsageModel { totalTokens: number; inputTokens: number; outputTokens: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + cacheHitRate?: number | null; + /** Input tokens whose cache detail was observed; hit rate is not model-wide below inputTokens. */ + cacheObservedInputTokens?: number; /** API list-price estimate for the priced portion of this row. */ estimatedCostUsd?: number; /** Requests included in the API list-price estimate. */ @@ -160,6 +166,16 @@ function UsageListPrice({ row, locale, t }: { row: UsageCostRow; locale: Locale; ); } +function formatOptionalTokens(value: number | undefined, locale: Locale, unavailable: string): string { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? formatTokens(value, locale) + : unavailable; +} + +function formatOptionalPct(value: number | null | undefined, unavailable: string): string { + return typeof value === "number" && Number.isFinite(value) ? formatPct(value) : unavailable; +} + // Stable per-model bar color: hash the provider/model id to a hue so the same model keeps its color // across days and renders. Saturation/lightness are fixed for a cohesive palette on the dark chart. function modelColor(model: string, provider: string): string { @@ -695,6 +711,7 @@ function UsageModelsTable({ const sectionLabel = t("usage.section.models"); const titleId = "usage-models-title"; const listPriceDisclaimerId = "usage-models-list-price-disclaimer"; + const unavailable = t("usage.unavailable"); const searchInput = ( {t("logs.col.provider")} {t("usage.col.requests")} {t("usage.col.measured")} + {t("usage.col.inputTokens")} + {t("usage.col.outputTokens")} + {t("usage.col.cacheHits")} + {t("usage.col.cacheWrites")} + {t("usage.col.cacheHitRate")} {t("usage.col.tokens")} {t("usage.col.apiListPrice")} {t("usage.col.share")} @@ -725,6 +747,18 @@ function UsageModelsTable({ {formatProviderDisplayName(model.provider, t)} {model.requests} {model.measuredRequests} + {formatTokens(model.inputTokens, locale)} + {formatTokens(model.outputTokens, locale)} + {formatOptionalTokens(model.cacheReadInputTokens ?? model.cachedInputTokens, locale, unavailable)} + {formatOptionalTokens(model.cacheCreationInputTokens, locale, unavailable)} + {formatOptionalPct( + typeof model.cacheObservedInputTokens === "number" + && Number.isFinite(model.cacheObservedInputTokens) + && model.cacheObservedInputTokens >= model.inputTokens + ? model.cacheHitRate + : null, + unavailable, + )} {formatTokens(model.totalTokens, locale)}
diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx index 3ef6373ccfe..2ff979604ec 100644 --- a/gui/tests/usage-custom-range.test.tsx +++ b/gui/tests/usage-custom-range.test.tsx @@ -81,6 +81,60 @@ function report(gate: RequestGate, marker: string, date = "2020-09-15") { }; } +test("Usage model table renders cache breakdown and marks unavailable telemetry", async () => { + await mount(); + const data = report(requests[0], "cache-model"); + data.models = [ + { + ...data.models[0]!, + model: "cache-model", + totalTokens: 1_120, + inputTokens: 1_000, + outputTokens: 120, + cachedInputTokens: 600, + cacheReadInputTokens: 600, + cacheCreationInputTokens: 100, + cacheHitRate: 0.6, + cacheObservedInputTokens: 1_000, + }, + { + ...data.models[0]!, + model: "partial-cache-model", + totalTokens: 1_000, + inputTokens: 1_000, + outputTokens: 0, + cachedInputTokens: 450, + cacheReadInputTokens: 450, + cacheCreationInputTokens: 0, + cacheHitRate: 0.9, + cacheObservedInputTokens: 500, + }, + { + ...data.models[0]!, + model: "unknown-cache-model", + totalTokens: 110, + inputTokens: 100, + outputTokens: 10, + }, + ]; + await act(async () => { requests[0]!.resolve(Response.json(data)); }); + + const table = container.querySelector("#usage-section-models table"); + expect(table).not.toBeNull(); + expect([...table!.querySelectorAll("thead th")].map(cell => cell.textContent?.trim())).toEqual([ + "Model", "Provider", "Requests", "Measured", "Input tokens", "Output tokens", + "Cache hits", "Cache writes", "Hit rate", "Tokens", "Share", + ]); + const rows = table!.querySelectorAll("tbody tr"); + expect(rows).toHaveLength(3); + const measured = [...rows[0]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); + expect(measured?.slice(4, 9)).toEqual(["1000", "120", "600", "100", "60%"]); + const partial = [...rows[1]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); + expect(partial?.slice(6, 9)).toEqual(["450", "0", "—"]); + const unavailable = [...rows[2]!.querySelectorAll("td")].map(cell => cell.textContent?.trim()); + expect(unavailable?.slice(6, 9)).toEqual(["—", "—", "—"]); +}); + async function respond(index: number, marker: string, date?: string) { await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); }); }