From 1bbf7afe178add2fadca399facfb884956a6cfb7 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/5] 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 2a889b8f2b..3528c96957 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 0cf7469f56..2267a11184 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -793,8 +793,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.", @@ -840,6 +842,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 576c3b7f23..b56713ca12 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -774,8 +774,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.", @@ -821,6 +823,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 1e01aea545..1617e03949 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 f1d20bf65e..509b225f3d 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 c583bccb99..8fa762bf83 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 ca39260677..d9e649f404 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 6462f6c4b5..11b005308a 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 685227abc0..4cbd4784d3 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 d60ef15f302fd5213540f57ce5155fc6ccc70d2a 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/5] 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 4aad44cec4b3083fbfd80fdd3169ee4d7ec8c87b 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/5] 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 8fa762bf83..28dc352ea0 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 ec55982254fa3e5ead11bf360863ec95e6b9bc6d 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/5] 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 f4f15435491b695d833885b7c5bdabdb4d352302 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/5] 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 b56713ca12..14691d10f1 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -774,7 +774,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 d9e649f404..d76d09a34d 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",