diff --git a/.github/pr-assets/opencodex-cache-usage.png b/.github/pr-assets/opencodex-cache-usage.png new file mode 100644 index 0000000000..6011c89b57 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 246791f284..1a718f6b8e 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 a98f0b1de0..67646c1bba 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 f738334aea..dfd8b99c30 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 6340b01a0a..4b0340280c 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 4989033e86..abc46d3169 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 e9e9bc08ad..29fc7cbb84 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 239784d355..dd6628a8f1 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 8dd4d98c1a..ff60ea51be 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 580bc13afe..bf1707e340 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 b53b4fc536..c9666bbbef 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 27c92df0d7..e09fcb5d03 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 de96b16e11..6c059b329b 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 3ef6373ccf..2ff979604e 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))); }); }