diff --git a/.github/pr-assets/estimated-decode-throughput.jpg b/.github/pr-assets/estimated-decode-throughput.jpg new file mode 100644 index 0000000000..b60d84a20d Binary files /dev/null and b/.github/pr-assets/estimated-decode-throughput.jpg differ 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..14691d10f1 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": "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", "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..28dc352ea0 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": "декодирование 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..d76d09a34d 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": "çö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", "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..11689b7b13 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-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. 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",