From 816702ee738ddbe4d20ade635dd31c5fd6ea049e Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:32:55 +0800 Subject: [PATCH 1/6] feat(logs): show estimated decode throughput --- 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/Logs.tsx | 29 +++++++++- gui/tests/logs-filter.test.ts | 11 ++++ src/server/management/shared.ts | 27 ++++++++- .../management-api-logs-metrics.test.ts | 58 ++++++++++++++++++- 13 files changed, 148 insertions(+), 4 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 88c29b7c9d..4c937e5113 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -746,8 +746,10 @@ export const de: Record = { "logs.col.status": "Status", "logs.col.tokens": "Tokens", "logs.col.tokPerSec": "tok/s", + "logs.col.decodeTokPerSec": "Decode-Tok/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "Ausgabe-Tokens pro Sekunde über die gesamte Anfragedauer", + "logs.metric.decodeTokPerSecTitle": "Geschätzte Ausgabe-Tokens pro Sekunde nach TTFT; Stream-Abschluss und Netzwerkpausen können weiterhin enthalten sein", "logs.metric.estimatedCostTitle": "API-Listenpreis-Äquivalent, keine tatsächliche Belastung; bei fehlendem Preisabgleich nicht verfügbar", "usage.cost.total": "API-Listenpreis-Äquivalent (dieser Zeitraum)", "usage.cost.disclaimer": "Kein Abrechnungsbeleg. Stattdessen können Abonnementnutzung oder Anbieter-Guthaben gelten.", @@ -793,6 +795,7 @@ export const de: Record = { "logs.detail.reason.usage_unsupported": "Dieser Anbieter meldet keine Nutzung.", "logs.detail.reason.output_missing": "Es wurden keine positiven Ausgabe-Tokens gemeldet.", "logs.detail.reason.invalid_duration": "Die Anfragedauer ist ungültig.", + "logs.detail.reason.ttft_missing": "Für diese Anfrage wurde keine TTFT erfasst.", "logs.detail.reason.price_unmatched": "Kein passender Preis gefunden.", "logs.detail.reason.invalid_cache_breakdown": "Cache-Token-Details widersprechen den Eingabe-Tokens.", "logs.detail.reason.invalid_usage": "Die Nutzung enthält einen ungültigen Token-Wert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 578311546f..073da81a28 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -795,8 +795,10 @@ export const en = { "logs.col.status": "Status", "logs.col.tokens": "Tokens", "logs.col.tokPerSec": "tok/s", + "logs.col.decodeTokPerSec": "decode tok/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "Output tokens per second over the full request duration", + "logs.metric.decodeTokPerSecTitle": "Estimated output tokens per second after TTFT; stream finalization and network pauses may still be included", "logs.metric.estimatedCostTitle": "API list-price equivalent, not an actual charge; unmatched pricing is unavailable", "usage.cost.total": "API list-price equivalent (this range)", "usage.cost.disclaimer": "Not a billing receipt. Subscription usage or provider credits may apply instead.", @@ -842,6 +844,7 @@ export const en = { "logs.detail.reason.usage_unsupported": "This provider does not report usage.", "logs.detail.reason.output_missing": "No positive output token count was reported.", "logs.detail.reason.invalid_duration": "The request duration is not valid.", + "logs.detail.reason.ttft_missing": "TTFT was not recorded for this request.", "logs.detail.reason.price_unmatched": "No matching price was found.", "logs.detail.reason.invalid_cache_breakdown": "Cache token details conflict with total input tokens.", "logs.detail.reason.invalid_usage": "Usage contains an invalid token value.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3c9d66243d..a0b1bf6223 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -776,8 +776,10 @@ export const fr: Record = { "logs.col.status": "État", "logs.col.tokens": "Jetons", "logs.col.tokPerSec": "jetons/s", + "logs.col.decodeTokPerSec": "décodage jetons/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "Jetons de sortie par seconde sur toute la durée de la requête", + "logs.metric.decodeTokPerSecTitle": "Estimation des jetons de sortie par seconde après le TTFT ; la finalisation du flux et les pauses réseau peuvent rester incluses", "logs.metric.estimatedCostTitle": "Équivalent au tarif catalogue de l’API, et non montant réellement facturé ; aucun tarif n’est disponible en l’absence de correspondance", "usage.cost.total": "Équivalent au tarif catalogue de l’API (cette période)", "usage.cost.disclaimer": "Ceci n’est pas un reçu de facturation. L’utilisation d’un abonnement ou les crédits du fournisseur peuvent s’appliquer à la place.", @@ -823,6 +825,7 @@ export const fr: Record = { "logs.detail.reason.usage_unsupported": "Ce fournisseur ne communique pas l’utilisation.", "logs.detail.reason.output_missing": "Aucun nombre positif de jetons de sortie n’a été communiqué.", "logs.detail.reason.invalid_duration": "La durée de la requête n’est pas valide.", + "logs.detail.reason.ttft_missing": "Le TTFT n’a pas été enregistré pour cette requête.", "logs.detail.reason.price_unmatched": "Aucun tarif correspondant n’a été trouvé.", "logs.detail.reason.invalid_cache_breakdown": "Le détail des jetons du cache est incompatible avec le nombre total de jetons d’entrée.", "logs.detail.reason.invalid_usage": "Les données d’utilisation contiennent une valeur de jetons non valide.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index d40c44bf53..e69b0b00de 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -708,8 +708,10 @@ export const ja: Record = { "logs.col.status": "状態", "logs.col.tokens": "トークン", "logs.col.tokPerSec": "tok/s", + "logs.col.decodeTokPerSec": "decode tok/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "リクエスト全体の所要時間あたりの出力トークン数", + "logs.metric.decodeTokPerSecTitle": "TTFT 後の推定出力 tok/s(ストリーム終了処理やネットワーク待機を含む場合があります)", "logs.metric.estimatedCostTitle": "API 定価相当額(実際の請求ではありません); 未対応の価格は利用できません", "usage.cost.total": "API 定価相当額(この期間)", "usage.cost.disclaimer": "請求明細ではありません。サブスクリプション利用量やプロバイダークレジットが代わりに適用される場合があります。", @@ -755,6 +757,7 @@ export const ja: Record = { "logs.detail.reason.usage_unsupported": "このプロバイダーは使用量を報告しません。", "logs.detail.reason.output_missing": "正の出力トークン数が報告されませんでした。", "logs.detail.reason.invalid_duration": "リクエストの所要時間が有効ではありません。", + "logs.detail.reason.ttft_missing": "このリクエストでは TTFT が記録されていません。", "logs.detail.reason.price_unmatched": "一致する価格が見つかりませんでした。", "logs.detail.reason.invalid_cache_breakdown": "キャッシュトークンの詳細が合計入力トークンと矛盾しています。", "logs.detail.reason.invalid_usage": "使用量に無効なトークン値が含まれています。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 126e0a3f0c..7aa3fd5a42 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -777,8 +777,10 @@ export const ko: Record = { "logs.col.status": "상태", "logs.col.tokens": "토큰", "logs.col.tokPerSec": "tok/s", + "logs.col.decodeTokPerSec": "decode tok/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "전체 요청 시간 기준 초당 출력 토큰", + "logs.metric.decodeTokPerSecTitle": "TTFT 이후 추정 초당 출력 토큰이며 스트림 종료 처리와 네트워크 지연이 포함될 수 있습니다", "logs.metric.estimatedCostTitle": "API 정가 환산치이며 실제 청구액이 아닙니다. 가격 미매칭은 표시하지 않습니다.", "usage.cost.total": "API 정가 환산치 (이 기간)", "usage.cost.disclaimer": "결제 영수증이 아닙니다. 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다.", @@ -824,6 +826,7 @@ export const ko: Record = { "logs.detail.reason.usage_unsupported": "이 프로바이더는 usage 보고를 지원하지 않습니다.", "logs.detail.reason.output_missing": "양수 출력 토큰 수가 보고되지 않았습니다.", "logs.detail.reason.invalid_duration": "요청 소요 시간이 유효하지 않습니다.", + "logs.detail.reason.ttft_missing": "이 요청에는 TTFT가 기록되지 않았습니다.", "logs.detail.reason.price_unmatched": "매칭되는 가격을 찾지 못했습니다.", "logs.detail.reason.invalid_cache_breakdown": "캐시 토큰 상세가 전체 입력 토큰과 모순됩니다.", "logs.detail.reason.invalid_usage": "usage에 유효하지 않은 토큰 값이 있습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7898a70c67..bd13564fc8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -763,8 +763,10 @@ export const ru: Record = { "logs.col.status": "Статус", "logs.col.tokens": "Токены", "logs.col.tokPerSec": "tok/s", + "logs.col.decodeTokPerSec": "decode tok/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "Выходные токены в секунду за полную длительность запроса", + "logs.metric.decodeTokPerSecTitle": "Оценка выходных токенов в секунду после TTFT; завершение потока и сетевые паузы могут оставаться включёнными", "logs.metric.estimatedCostTitle": "Эквивалент стоимости по прайс-листу API, а не фактическое списание; если цену не удалось сопоставить, значение недоступно", "usage.cost.total": "Эквивалент стоимости по прайс-листу API (за этот период)", "usage.cost.disclaimer": "Не является счётом. Расходы могут покрываться подпиской или кредитами провайдера.", @@ -810,6 +812,7 @@ export const ru: Record = { "logs.detail.reason.usage_unsupported": "Этот провайдер не сообщает данные об использовании.", "logs.detail.reason.output_missing": "Положительное число выходных токенов не было сообщено.", "logs.detail.reason.invalid_duration": "Длительность запроса некорректна.", + "logs.detail.reason.ttft_missing": "Для этого запроса TTFT не был записан.", "logs.detail.reason.price_unmatched": "Подходящая цена не найдена.", "logs.detail.reason.invalid_cache_breakdown": "Детализация кэш-токенов противоречит общему числу входных токенов.", "logs.detail.reason.invalid_usage": "В данных использования есть некорректное значение токенов.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 6397b05022..764c69f297 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -782,8 +782,10 @@ export const tr: Record = { "logs.col.status": "Durum", "logs.col.tokens": "Jetonlar", "logs.col.tokPerSec": "jeton/sn", + "logs.col.decodeTokPerSec": "decode jeton/sn", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "Tam istek süresince saniye başına çıktı jetonu", + "logs.metric.decodeTokPerSecTitle": "TTFT sonrasında saniye başına tahmini çıktı jetonu; akış sonlandırma ve ağ beklemeleri hâlâ dahil olabilir", "logs.metric.estimatedCostTitle": "Tahmini API liste fiyatı", "usage.cost.total": "API liste fiyatı eşdeğeri", "usage.cost.disclaimer": "Fatura makbuzu değildir.", @@ -829,6 +831,7 @@ export const tr: Record = { "logs.detail.reason.usage_unsupported": "Bu sağlayıcı kullanım bildirmeyebilir.", "logs.detail.reason.output_missing": "Çıktı jeton sayısı bildirilmedi.", "logs.detail.reason.invalid_duration": "İstek süresi geçersiz.", + "logs.detail.reason.ttft_missing": "Bu istek için TTFT kaydedilmedi.", "logs.detail.reason.price_unmatched": "Eşleşen fiyat bulunamadı.", "logs.detail.reason.invalid_cache_breakdown": "Önbellek detayları çakışıyor.", "logs.detail.reason.invalid_usage": "Kullanım geçersiz bir jeton değeri içeriyor.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index f2777a2591..f852b7bfeb 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -612,8 +612,10 @@ export const zhTW: Record = { "logs.col.status": "狀態", "logs.col.tokens": "Token 數", "logs.col.tokPerSec": "tok/s", + "logs.col.decodeTokPerSec": "解碼 tok/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "按完整請求耗時計算的每秒輸出 token", + "logs.metric.decodeTokPerSecTitle": "扣除 TTFT 後的估算每秒輸出 token;仍可能包含串流收尾與網路停頓", "logs.metric.estimatedCostTitle": "按 API 標價估算,並非實際扣費;價格無法符合時不顯示", "usage.cost.total": "API 標價折算(當前範圍)", "usage.cost.disclaimer": "這不是帳單或扣費憑證。實際可能計入訂閱用量或消耗服務商額度。", @@ -643,6 +645,7 @@ export const zhTW: Record = { "logs.detail.reason.usage_unsupported": "該供應商不支援上報 usage。", "logs.detail.reason.output_missing": "未上報正數輸出 token。", "logs.detail.reason.invalid_duration": "請求耗時無效。", + "logs.detail.reason.ttft_missing": "此請求未記錄 TTFT。", "logs.detail.reason.price_unmatched": "未找到符合的 jawcode 價格。", "logs.detail.reason.invalid_cache_breakdown": "快取 token 明細與輸入 token 總數衝突。", "logs.detail.reason.invalid_usage": "Usage 包含無效的 token 值。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 36f81b44f1..c68dd77b79 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -758,8 +758,10 @@ export const zh: Record = { "logs.col.status": "状态", "logs.col.tokens": "Token 数", "logs.col.tokPerSec": "tok/s", + "logs.col.decodeTokPerSec": "解码 tok/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "按完整请求耗时计算的每秒输出 token", + "logs.metric.decodeTokPerSecTitle": "扣除 TTFT 后的估算每秒输出 token;仍可能包含流式收尾和网络停顿", "logs.metric.estimatedCostTitle": "按 API 标价估算,并非实际扣费;价格无法匹配时不显示", "usage.cost.total": "API 标价折算(当前范围)", "usage.cost.disclaimer": "这不是账单或扣费凭证。实际可能计入订阅用量或消耗服务商额度。", @@ -805,6 +807,7 @@ export const zh: Record = { "logs.detail.reason.usage_unsupported": "该提供方不支持上报 usage。", "logs.detail.reason.output_missing": "未上报正数输出 token。", "logs.detail.reason.invalid_duration": "请求耗时无效。", + "logs.detail.reason.ttft_missing": "该请求未记录 TTFT。", "logs.detail.reason.price_unmatched": "未找到匹配的价格。", "logs.detail.reason.invalid_cache_breakdown": "缓存 token 明细与输入 token 总数冲突。", "logs.detail.reason.invalid_usage": "Usage 包含无效的 token 值。", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 03a0a45005..ef0e471b53 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -52,6 +52,7 @@ type LogUsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; type MetricUnavailableReason = | "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration" + | "ttft_missing" | "price_unmatched" | "invalid_cache_breakdown" | "invalid_usage" | "combo_attempt_unavailable"; @@ -92,6 +93,7 @@ type CostResult = interface LogDisplayMetrics { tokPerSecond: TokPerSecondResult; + decodeTokPerSecond: TokPerSecondResult; cost: CostResult; } @@ -272,6 +274,7 @@ const METRIC_REASON_KEYS = { usage_unsupported: "logs.detail.reason.usage_unsupported", output_missing: "logs.detail.reason.output_missing", invalid_duration: "logs.detail.reason.invalid_duration", + ttft_missing: "logs.detail.reason.ttft_missing", price_unmatched: "logs.detail.reason.price_unmatched", invalid_cache_breakdown: "logs.detail.reason.invalid_cache_breakdown", invalid_usage: "logs.detail.reason.invalid_usage", @@ -752,7 +755,14 @@ export default function Logs({ apiBase }: { apiBase: string }) { {t("logs.col.time")} {t("logs.col.tokens")} - {t("logs.col.tokPerSec")} + + + {t("logs.col.tokPerSec")} + + {t("logs.col.decodeTokPerSec")} + + + {t("logs.col.estimatedCost")} {t("logs.col.model")} {t("logs.col.effort")} @@ -813,7 +823,12 @@ export default function Logs({ apiBase }: { apiBase: string }) { })()} - {formatTokPerSecond(log.displayMetrics?.tokPerSecond, localeTag)} + + {formatTokPerSecond(log.displayMetrics?.tokPerSecond, localeTag)} + + {formatTokPerSecond(log.displayMetrics?.decodeTokPerSecond, localeTag)} + + {formatEstimatedUsd(log.displayMetrics?.cost, t, localeTag)} @@ -1033,6 +1048,8 @@ function LogDetailDialog({
{t("logs.col.duration")}{detail.durationMs}ms {t("logs.col.tokPerSec")}{formatTokPerSecond(detail.displayMetrics?.tokPerSecond, localeTag)} + {t("logs.col.decodeTokPerSec")} + {formatTokPerSecond(detail.displayMetrics?.decodeTokPerSecond, localeTag)} {detail.firstOutputMs !== undefined && ( <>{t("logs.detail.ttft")}{detail.firstOutputMs}ms )} @@ -1040,6 +1057,12 @@ function LogDetailDialog({ {detail.displayMetrics?.tokPerSecond.kind === "unavailable" && (

{t(metricReasonKey(detail.displayMetrics.tokPerSecond.reason))}

)} + {detail.displayMetrics?.tokPerSecond.kind === "value" + && detail.displayMetrics.decodeTokPerSecond.kind === "unavailable" && ( +

+ {t("logs.col.decodeTokPerSec")}: {t(metricReasonKey(detail.displayMetrics.decodeTokPerSecond.reason))} +

+ )}
@@ -1088,6 +1111,7 @@ function LogDetailDialog({ {t("logs.detail.attempt.target")} {t("logs.col.duration")} {t("logs.col.tokPerSec")} + {t("logs.col.decodeTokPerSec")} {t("logs.col.estimatedCost")} {t("logs.detail.attempt.reason")} @@ -1125,6 +1149,7 @@ function LogDetailDialog({ {attempt.durationMs}ms {formatTokPerSecond(attempt.displayMetrics?.tokPerSecond, localeTag)} + {formatTokPerSecond(attempt.displayMetrics?.decodeTokPerSecond, localeTag)} {formatEstimatedUsd(attemptCost, t, localeTag)} {reason} diff --git a/gui/tests/logs-filter.test.ts b/gui/tests/logs-filter.test.ts index a0f416d6de..1edd52b1c3 100644 --- a/gui/tests/logs-filter.test.ts +++ b/gui/tests/logs-filter.test.ts @@ -109,6 +109,17 @@ describe("rich Logs filtering", () => { expect(filterLogs([...logs, unavailable], { ...DEFAULT_LOG_FILTER_STATE, minTokPerSec: 50 }, NOW).map(row => row.id)).toEqual(["codex", "helper"]); }); + test("keeps speed filters on end-to-end tok/s when decode throughput is higher", () => { + const rows = [{ + id: "slow-e2e-fast-decode", + displayMetrics: { + tokPerSecond: { kind: "value" as const, value: 10 }, + decodeTokPerSecond: { kind: "value" as const, value: 100 }, + }, + }]; + expect(filterLogs(rows, { ...DEFAULT_LOG_FILTER_STATE, minTokPerSec: 50 }, NOW)).toEqual([]); + }); + test("extracts sorted unique options and ignores malformed attempts", () => { const options = extractLogFilterOptions([ ...logs, diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 6591bddd1b..dad310c926 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -78,6 +78,7 @@ export function parseDebugLogQuery(url: URL): { after: number; limit: number } { export type MetricUnavailableReason = | "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration" + | "ttft_missing" | "price_unmatched" | "invalid_cache_breakdown" | "invalid_usage" | "combo_attempt_unavailable"; @@ -96,7 +97,7 @@ export type CostResult = | { kind: "value"; estimate: NonNullable>; estimateReasons: CostEstimateReason[] } | { kind: "unavailable"; reason: MetricUnavailableReason }; -export type MetricSource = Pick & { +export type MetricSource = Pick & { attempts?: readonly PersistedUsageAttempt[]; }; @@ -113,6 +114,28 @@ export function tokPerSecondResult(entry: Pick, +): TokPerSecondResult { + if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" }; + if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" }; + if (entry.usage.outputTokens <= 0) return { kind: "unavailable", reason: "output_missing" }; + if (entry.firstOutputMs === undefined) return { kind: "unavailable", reason: "ttft_missing" }; + if (!Number.isFinite(entry.firstOutputMs) || entry.firstOutputMs < 0) { + return { kind: "unavailable", reason: "invalid_duration" }; + } + const decodeDurationMs = entry.durationMs - entry.firstOutputMs; + const value = tokensPerSecond(entry.usage.outputTokens, decodeDurationMs); + if (value === null) return { kind: "unavailable", reason: "invalid_duration" }; + return { kind: "value", value, estimated: true }; +} + export function unavailableCostReason(entry: MetricSource): MetricUnavailableReason { // Normalizer-first classification: the landed normalizer recovers legacy // cachedInputTokens=read+write rows via retry, so a raw read+write>input @@ -157,6 +180,7 @@ export function requestLogDto(entry: RequestLogEntry): Record { ...entry, displayMetrics: { tokPerSecond: tokPerSecondResult(entry), + decodeTokPerSecond: decodeTokPerSecondResult(entry), cost: costResult(entry), }, ...(entry.attempts?.length @@ -165,6 +189,7 @@ export function requestLogDto(entry: RequestLogEntry): Record { ...attempt, displayMetrics: { tokPerSecond: tokPerSecondResult(attempt), + decodeTokPerSecond: decodeTokPerSecondResult(attempt), cost: costResult({ ...attempt, attempts: undefined, routeDecision: entry.routeDecision, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }), }, })), diff --git a/tests/server/management-api-logs-metrics.test.ts b/tests/server/management-api-logs-metrics.test.ts index f811810809..7b9762b4ed 100644 --- a/tests/server/management-api-logs-metrics.test.ts +++ b/tests/server/management-api-logs-metrics.test.ts @@ -158,12 +158,14 @@ describe("GET /api/logs display metrics", () => { expect(body.logs?.map(row => row.requestId)).toEqual(["ok-b"]); }); - test("adds tok/s and cost without mutating the stored log", async () => { + test("adds end-to-end and estimated decode tok/s without mutating the stored log", async () => { addRequestLog(baseEntry({ + firstOutputMs: 500, usage: { inputTokens: 1000, outputTokens: 240 }, })); const [dto] = await readLogs(); expect(dto!.displayMetrics.tokPerSecond).toEqual({ kind: "value", value: 120, estimated: false }); + expect(dto!.displayMetrics.decodeTokPerSecond).toEqual({ kind: "value", value: 160, estimated: true }); expect(dto!.displayMetrics.cost.kind).toBe("value"); expect(dto!.displayMetrics.cost.estimate.cost.total).toBeGreaterThan(0); expect(dto!.displayMetrics.cost.estimate.price.source).toBe("jawcode"); @@ -171,6 +173,60 @@ describe("GET /api/logs display metrics", () => { expect(Object.hasOwn(getRequestLogEntries()[0]!, "displayMetrics")).toBe(false); }); + test("decode tok/s requires TTFT and a positive post-TTFT duration", async () => { + addRequestLog(baseEntry({ + requestId: "missing-ttft", + usage: { inputTokens: 100, outputTokens: 10 }, + })); + addRequestLog(baseEntry({ + requestId: "invalid-post-ttft-duration", + durationMs: 2_000, + firstOutputMs: 2_000, + usage: { inputTokens: 100, outputTokens: 10 }, + })); + addRequestLog(baseEntry({ + requestId: "invalid-negative-ttft", + firstOutputMs: -1, + usage: { inputTokens: 100, outputTokens: 10 }, + })); + + const logs = await readLogs(); + const byId = new Map(logs.map(log => [log.requestId, log])); + expect(byId.get("missing-ttft")!.displayMetrics.decodeTokPerSecond) + .toEqual({ kind: "unavailable", reason: "ttft_missing" }); + expect(byId.get("invalid-post-ttft-duration")!.displayMetrics.decodeTokPerSecond) + .toEqual({ kind: "unavailable", reason: "invalid_duration" }); + expect(byId.get("invalid-negative-ttft")!.displayMetrics.decodeTokPerSecond) + .toEqual({ kind: "unavailable", reason: "invalid_duration" }); + }); + + test("combo attempts use their own attempt-relative TTFT for decode tok/s", async () => { + addRequestLog(baseEntry({ + durationMs: 10_000, + firstOutputMs: 2_000, + usage: { inputTokens: 100, outputTokens: 240 }, + attempts: [{ + ordinal: 1, + provider: "anthropic", + model: "claude-3-haiku-20240307", + adapter: "anthropic", + status: 200, + durationMs: 4_000, + firstOutputMs: 500, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 70 }, + }], + })); + + const [dto] = await readLogs(); + expect(dto!.displayMetrics.decodeTokPerSecond) + .toEqual({ kind: "value", value: 30, estimated: true }); + expect(dto!.attempts[0].displayMetrics.decodeTokPerSecond) + .toEqual({ kind: "value", value: 20, estimated: true }); + }); + test("estimated positive output marks tok/s estimated and keeps cost value", async () => { addRequestLog(baseEntry({ usageStatus: "estimated", From 1bc9a7c0f31ab0e4c3407912b937404bb96c7901 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:47:26 +0800 Subject: [PATCH 2/6] docs(pr): add decode throughput screenshot --- .../pr-assets/estimated-decode-throughput.jpg | Bin 0 -> 1129 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/pr-assets/estimated-decode-throughput.jpg diff --git a/.github/pr-assets/estimated-decode-throughput.jpg b/.github/pr-assets/estimated-decode-throughput.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b60d84a20d36ed202c4a3c3316a190c44b18aaa5 GIT binary patch literal 1129 zcmex=^lOiET&UP@Y7ModgWM?qOlT~kX_QeM|USHnP6LsJ7}2qQZ?I~NC+Fc+7w zhLo6;2Fc+60R}-126Ki5%#2D5OoEKef{g!# zB){M@2~R#nJWl+z#ww{w{$aA`;Xf{?s&>3Am57l0^kdfby^RY_J>MZyb*ITbKzZxF z7q+`z&RaKcp2y;O8INn0w3(jUSlW7af9t|aU)IL)I!#-&KA>jd+HBQy(;GXA)_CM+ zKfm@{PbP5r>Bu!NH<``zRBUqcE6@;zjRMA>Dmhvvg&pGDhp*7_mPRt zyUYIfS8URMhTtQwBC?cI*fb^2oV|Q=t>ojY*S6Px-^4Rd>q^#^y;fGU+?*U{nVotW z6LoFF4kk~A+s|L#%`ElPnY(WF@@0){YYI7~b^OoF>#<~;c4NyE2lH3%sV^5@NvxY- z%H-HS^{mb2`Q9nu5p;&om;dcM2lMn5B|NP44Gl?l`nwF_+CeN^r|IWbpPj7efwTz|9+g8op zb5gyekgdUZcS8aD!*wVAuG_p~b>{2EB~vyPOYm-DXsF|S|4AT4Nr_1_y^`M(h&{AG zEU(CW&X@Q+md<@*s|v&lEAX7G93$Tr6Z{u}P;}dy7TGrT4S>Kb1#D#m)3z*14a5?z6i5 k6W{Hb7oFL>r+ul;d&QURDnT&qAuf=_3{KI=g#7=T0RE!7TL1t6 literal 0 HcmV?d00001 From d382916f91d0d02aa30cc0259a96fcbc43730efd Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:58:41 +0800 Subject: [PATCH 3/6] fix(i18n): clarify Russian decode throughput copy --- gui/src/i18n/ru.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bd13564fc8..65f715d5c1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -763,10 +763,10 @@ export const ru: Record = { "logs.col.status": "Статус", "logs.col.tokens": "Токены", "logs.col.tokPerSec": "tok/s", - "logs.col.decodeTokPerSec": "decode tok/s", + "logs.col.decodeTokPerSec": "декодирование tok/s", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "Выходные токены в секунду за полную длительность запроса", - "logs.metric.decodeTokPerSecTitle": "Оценка выходных токенов в секунду после TTFT; завершение потока и сетевые паузы могут оставаться включёнными", + "logs.metric.decodeTokPerSecTitle": "Оценка выходных токенов в секунду после TTFT; измеренный интервал всё ещё включает завершение потока и сетевые паузы", "logs.metric.estimatedCostTitle": "Эквивалент стоимости по прайс-листу API, а не фактическое списание; если цену не удалось сопоставить, значение недоступно", "usage.cost.total": "Эквивалент стоимости по прайс-листу API (за этот период)", "usage.cost.disclaimer": "Не является счётом. Расходы могут покрываться подпиской или кредитами провайдера.", From e387cee17473c04491e84715b5bb03847b0d3a7f Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:01:36 +0800 Subject: [PATCH 4/6] fix(logs): tolerate cached metrics without decode rate --- gui/src/pages/Logs.tsx | 2 +- gui/tests/logs-auto-refresh.test.tsx | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index ef0e471b53..11689b7b13 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -1058,7 +1058,7 @@ function LogDetailDialog({

{t(metricReasonKey(detail.displayMetrics.tokPerSecond.reason))}

)} {detail.displayMetrics?.tokPerSecond.kind === "value" - && detail.displayMetrics.decodeTokPerSecond.kind === "unavailable" && ( + && detail.displayMetrics.decodeTokPerSecond?.kind === "unavailable" && (

{t("logs.col.decodeTokPerSec")}: {t(metricReasonKey(detail.displayMetrics.decodeTokPerSecond.reason))}

diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx index 200b6c4f23..8234d77500 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -520,6 +520,24 @@ test("Logs: inside-card clicks keep the detail dialog open; backdrop dismiss clo await act(async () => { root.unmount(); }); }); +test("Logs: a cached pre-decode-metric row opens its detail dialog", async () => { + sessionStorage.setItem("ocx.logs.list.v1:http://localhost", JSON.stringify([sampleLog])); + globalThis.fetch = (async (input) => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + return jsonResponse([sampleLog]); + }) as typeof fetch; + + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await act(async () => { container.querySelector(".log-detail-btn")!.click(); }); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(container.querySelector(".log-detail-card")?.textContent).toContain("tok/s"); + } finally { + await act(async () => { root.unmount(); }); + } +}); + // #2157: the Codex App sends helper requests on every message and turn completion. That // traffic is the App's, not ours -- what is ours is making an INTERCEPTED one identifiable, so // the reporter can tell recurring helper spend from their own work. From e7bb326554f08670bf14e0a6cde32a6690993bfe Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:35:39 +0800 Subject: [PATCH 5/6] fix(i18n): clarify decode throughput labels --- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/tr.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a0b1bf6223..a3017da356 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -776,7 +776,7 @@ export const fr: Record = { "logs.col.status": "État", "logs.col.tokens": "Jetons", "logs.col.tokPerSec": "jetons/s", - "logs.col.decodeTokPerSec": "décodage jetons/s", + "logs.col.decodeTokPerSec": "jetons/s (décodage)", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "Jetons de sortie par seconde sur toute la durée de la requête", "logs.metric.decodeTokPerSecTitle": "Estimation des jetons de sortie par seconde après le TTFT ; la finalisation du flux et les pauses réseau peuvent rester incluses", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 764c69f297..7ac844c490 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -782,7 +782,7 @@ export const tr: Record = { "logs.col.status": "Durum", "logs.col.tokens": "Jetonlar", "logs.col.tokPerSec": "jeton/sn", - "logs.col.decodeTokPerSec": "decode jeton/sn", + "logs.col.decodeTokPerSec": "çözümleme jeton/sn", "logs.col.estimatedCost": "~$", "logs.metric.tokPerSecTitle": "Tam istek süresince saniye başına çıktı jetonu", "logs.metric.decodeTokPerSecTitle": "TTFT sonrasında saniye başına tahmini çıktı jetonu; akış sonlandırma ve ağ beklemeleri hâlâ dahil olabilir", From 5f5c7972c56d755fb290b453205b3ae8fd82c7d9 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:21:01 +0800 Subject: [PATCH 6/6] docs(pr): improve decode throughput screenshot --- .../pr-assets/estimated-decode-throughput.jpg | Bin 1129 -> 33173 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/pr-assets/estimated-decode-throughput.jpg b/.github/pr-assets/estimated-decode-throughput.jpg index b60d84a20d36ed202c4a3c3316a190c44b18aaa5..faf52db2352235ad709181b29746627afdb2a1c9 100644 GIT binary patch literal 33173 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