diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index f662d356e..da56878e2 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -61,13 +61,13 @@ "filters": { "user": "User", "provider": "Provider", - "sessionId": "Session ID", + "sessionId": "Session / Prefix ID", "searchUser": "Search users...", "searchProvider": "Search providers...", - "searchSessionId": "Search session IDs...", + "searchSessionId": "Search Session or Prefix IDs...", "noUserFound": "No matching users found", "noProviderFound": "No matching providers found", - "noSessionFound": "No matching session IDs found", + "noSessionFound": "No matching Session or Prefix IDs found", "model": "Model", "actualResponseModelMismatch": "Billing/actual model mismatch", "endpoint": "Endpoint", @@ -221,6 +221,23 @@ "viewFullError": "View full error", "viewSession": "View Session" }, + "cachePerformance": { + "title": "Cache Performance", + "actualRate": "Actual Cache Rate", + "theoreticalRate": "Theoretical Cache Rate", + "coefficient": "Request Cache Coefficient", + "rawCoefficient": "Raw single-request coefficient; provider confidence is not applied.", + "estimateNote": "The theoretical rate is estimated from the longest normalized prefix (bytes / 4).", + "availability": { + "available": "Eligible request with a measurable prefix cache estimate.", + "no_input": "No input tokens were recorded for this request.", + "no_affinity_key": "No Prefix ID was recorded, so the theoretical rate and coefficient are unavailable.", + "attempt_failed": "The request attempt failed; the coefficient is excluded.", + "not_observable": "Upstream cache usage was not observable; the coefficient is excluded.", + "stream_truncated": "The stream was truncated; the coefficient is excluded.", + "not_recorded": "Prefix cache metrics were not recorded for this request." + } + }, "specialSettings": { "title": "Special settings" }, @@ -381,6 +398,8 @@ "metadata": { "noMetadata": "No metadata available", "sessionInfo": "Session Info", + "sessionId": "Session ID", + "prefixId": "Prefix ID", "clientInfo": "Client Info", "billingInfo": "Billing Info", "technicalTimeline": "Technical Timeline", @@ -695,7 +714,7 @@ "avgCostPerMillionTokens": "Avg Cost/1M Tokens", "unknownModel": "Unknown", "successRateUnavailable": "N/A", - "successRateBasisDisclosure": "Success rate is unavailable here because redirected billing mode can merge multiple original models into one row." + "successRateBasisDisclosure": "No countable success/failure outcomes were recorded for this model in the selected period." }, "expandModelStats": "Expand model details", "collapseModelStats": "Collapse model details", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 05c297057..13b6a99d4 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -61,13 +61,13 @@ "filters": { "user": "ユーザー", "provider": "プロバイダー", - "sessionId": "セッションID", + "sessionId": "Session / Prefix ID", "searchUser": "ユーザーを検索...", "searchProvider": "プロバイダーを検索...", - "searchSessionId": "セッションIDを検索...", + "searchSessionId": "Session または Prefix ID を検索...", "noUserFound": "一致するユーザーが見つかりません", "noProviderFound": "一致するプロバイダーが見つかりません", - "noSessionFound": "一致するセッションIDが見つかりません", + "noSessionFound": "一致する Session または Prefix ID が見つかりません", "model": "モデル", "actualResponseModelMismatch": "課金/実応答モデル不一致", "endpoint": "エンドポイント", @@ -221,6 +221,23 @@ "viewFullError": "完全なエラーを表示", "viewSession": "セッションを表示" }, + "cachePerformance": { + "title": "キャッシュ性能", + "actualRate": "実測キャッシュ率", + "theoreticalRate": "理論キャッシュ率", + "coefficient": "リクエストキャッシュ係数", + "rawCoefficient": "単一リクエストの生係数。プロバイダーの信頼度補正は適用しません。", + "estimateNote": "理論キャッシュ率は最長の正規化プレフィックス (バイト数 / 4) から推定されます。", + "availability": { + "available": "測定可能なプレフィックスキャッシュ推定値を持つ対象リクエストです。", + "no_input": "入力トークンが記録されていません。", + "no_affinity_key": "Prefix ID が記録されていないため、理論率と係数は利用できません。", + "attempt_failed": "リクエスト試行が失敗したため、係数は除外されています。", + "not_observable": "上流のキャッシュ使用量を観測できないため、係数は除外されています。", + "stream_truncated": "ストリームが途中で切断されたため、係数は除外されています。", + "not_recorded": "このリクエストではリクエスト単位のプレフィックスキャッシュ指標が記録されていません。" + } + }, "specialSettings": { "title": "特殊設定" }, @@ -381,6 +398,8 @@ "metadata": { "noMetadata": "メタデータがありません", "sessionInfo": "セッション情報", + "sessionId": "Session ID", + "prefixId": "Prefix ID", "clientInfo": "クライアント情報", "billingInfo": "課金情報", "technicalTimeline": "技術タイムライン", @@ -695,7 +714,7 @@ "avgCostPerMillionTokens": "100万トークンあたりコスト", "unknownModel": "不明", "successRateUnavailable": "利用不可", - "successRateBasisDisclosure": "redirected 課金モデルでは 1 行に複数の元モデルが混在しうるため、誤解を避けるためここでは成功率を表示しません。" + "successRateBasisDisclosure": "選択した期間にこのモデルの集計可能な成功/失敗結果がないため、成功率を計算できません。" }, "expandModelStats": "モデル詳細を展開", "collapseModelStats": "モデル詳細を折りたたむ", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index d4e3ea0d9..a5eb3f2dc 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -61,13 +61,13 @@ "filters": { "user": "Пользователь", "provider": "Поставщик", - "sessionId": "ID сессии", + "sessionId": "Session / Prefix ID", "searchUser": "Поиск пользователей...", "searchProvider": "Поиск провайдеров...", - "searchSessionId": "Поиск ID сессии...", + "searchSessionId": "Поиск Session или Prefix ID...", "noUserFound": "Пользователи не найдены", "noProviderFound": "Провайдеры не найдены", - "noSessionFound": "ID сессии не найдены", + "noSessionFound": "Session или Prefix ID не найдены", "model": "Модель", "actualResponseModelMismatch": "Модель тарификации отличается от фактической", "endpoint": "Эндпоинт", @@ -221,6 +221,23 @@ "viewFullError": "Показать полную ошибку", "viewSession": "Просмотр сеанса" }, + "cachePerformance": { + "title": "Эффективность кэша", + "actualRate": "Фактическая доля кэша", + "theoreticalRate": "Теоретическая доля кэша", + "coefficient": "Коэффициент кэша запроса", + "rawCoefficient": "Необработанный коэффициент одного запроса; поправка доверия провайдера не применяется.", + "estimateNote": "Теоретическая доля оценивается по самому длинному нормализованному префиксу (байты / 4).", + "availability": { + "available": "Запрос подходит для оценки кэша префикса.", + "no_input": "Для запроса не записаны входные токены.", + "no_affinity_key": "Prefix ID не записан, поэтому теоретическая доля и коэффициент недоступны.", + "attempt_failed": "Попытка запроса завершилась ошибкой; коэффициент исключен.", + "not_observable": "Использование кэша у провайдера не наблюдалось; коэффициент исключен.", + "stream_truncated": "Поток был усечен; коэффициент исключен.", + "not_recorded": "Для этого запроса не записаны метрики кэша префикса." + } + }, "specialSettings": { "title": "Особые настройки" }, @@ -381,6 +398,8 @@ "metadata": { "noMetadata": "Нет метаданных", "sessionInfo": "Информация о сеансе", + "sessionId": "Session ID", + "prefixId": "Prefix ID", "clientInfo": "Информация о клиенте", "billingInfo": "Информация о биллинге", "technicalTimeline": "Техническая хронология", @@ -695,7 +714,7 @@ "avgCostPerMillionTokens": "Ср. стоимость/1М токенов", "unknownModel": "Неизвестно", "successRateUnavailable": "Н/Д", - "successRateBasisDisclosure": "В режиме redirected billing одна строка может объединять несколько исходных моделей, поэтому здесь успешность скрыта, чтобы не вводить в заблуждение." + "successRateBasisDisclosure": "За выбранный период у этой модели нет учитываемых успешных или неуспешных исходов, поэтому успешность не рассчитывается." }, "expandModelStats": "Развернуть модели", "collapseModelStats": "Свернуть модели", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index bb26357b4..7da08116f 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -61,13 +61,13 @@ "filters": { "user": "用户", "provider": "供应商", - "sessionId": "Session ID", + "sessionId": "Session / Prefix ID", "searchUser": "搜索用户...", "searchProvider": "搜索供应商...", - "searchSessionId": "搜索 Session ID...", + "searchSessionId": "搜索 Session 或 Prefix ID...", "noUserFound": "未找到匹配的用户", "noProviderFound": "未找到匹配的供应商", - "noSessionFound": "未找到匹配的 Session ID", + "noSessionFound": "未找到匹配的 Session 或 Prefix ID", "model": "模型", "actualResponseModelMismatch": "计费/实际模型不一致", "endpoint": "端点", @@ -221,6 +221,23 @@ "viewFullError": "查看完整错误", "viewSession": "查看会话" }, + "cachePerformance": { + "title": "缓存表现", + "actualRate": "实际缓存率", + "theoreticalRate": "理论缓存率", + "coefficient": "本请求缓存系数", + "rawCoefficient": "单请求原始系数,不应用供应商窗口置信度折扣。", + "estimateNote": "理论缓存率根据最长规范化前缀估算(字节数 / 4)。", + "availability": { + "available": "请求符合条件,前缀缓存估算可测量。", + "no_input": "该请求没有记录输入令牌。", + "no_affinity_key": "未记录 Prefix ID,因此理论缓存率和缓存系数不可用。", + "attempt_failed": "请求尝试失败,缓存系数已排除。", + "not_observable": "无法观测上游缓存用量,缓存系数已排除。", + "stream_truncated": "响应流被截断,缓存系数已排除。", + "not_recorded": "该请求未记录单请求前缀缓存指标。" + } + }, "specialSettings": { "title": "特殊设置" }, @@ -381,6 +398,8 @@ "metadata": { "noMetadata": "暂无元数据", "sessionInfo": "会话信息", + "sessionId": "Session ID", + "prefixId": "Prefix ID", "clientInfo": "客户端信息", "billingInfo": "计费信息", "technicalTimeline": "技术时间线", @@ -695,7 +714,7 @@ "avgCostPerMillionTokens": "平均百万 Token 成本", "unknownModel": "未知", "successRateUnavailable": "不适用", - "successRateBasisDisclosure": "在 redirected 计费模型模式下,一行可能合并多个原始模型,因此这里不展示成功率以避免口径误导。" + "successRateBasisDisclosure": "所选时间段内该模型没有可计数的成功或失败结果,因此无法计算成功率。" }, "expandModelStats": "展开模型详情", "collapseModelStats": "收起模型详情", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 0ac264d7f..35ffc49a3 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -61,13 +61,13 @@ "filters": { "user": "使用者", "provider": "供應商", - "sessionId": "Session ID", + "sessionId": "Session / Prefix ID", "searchUser": "搜尋使用者...", "searchProvider": "搜尋供應商...", - "searchSessionId": "搜尋 Session ID...", + "searchSessionId": "搜尋 Session 或 Prefix ID...", "noUserFound": "未找到匹配的使用者", "noProviderFound": "未找到匹配的供應商", - "noSessionFound": "未找到符合的 Session ID", + "noSessionFound": "未找到符合的 Session 或 Prefix ID", "model": "Model", "actualResponseModelMismatch": "計費/實際模型不一致", "endpoint": "端點", @@ -221,6 +221,23 @@ "viewFullError": "查看完整錯誤", "viewSession": "查看工作階段" }, + "cachePerformance": { + "title": "快取表現", + "actualRate": "實際快取率", + "theoreticalRate": "理論快取率", + "coefficient": "本次請求快取係數", + "rawCoefficient": "單次請求原始係數,不套用供應商視窗信心折扣。", + "estimateNote": "理論快取率根據最長正規化前綴估算(位元組數 / 4)。", + "availability": { + "available": "請求符合條件,可測量前綴快取估算。", + "no_input": "此請求沒有記錄輸入 token。", + "no_affinity_key": "未記錄 Prefix ID,因此理論快取率和快取係數不可用。", + "attempt_failed": "請求嘗試失敗,快取係數已排除。", + "not_observable": "無法觀測上游快取用量,快取係數已排除。", + "stream_truncated": "回應串流被截斷,快取係數已排除。", + "not_recorded": "此請求未記錄單次前綴快取指標。" + } + }, "specialSettings": { "title": "特殊設定" }, @@ -381,6 +398,8 @@ "metadata": { "noMetadata": "暫無中繼資料", "sessionInfo": "工作階段資訊", + "sessionId": "Session ID", + "prefixId": "Prefix ID", "clientInfo": "用戶端資訊", "billingInfo": "計費資訊", "technicalTimeline": "技術時間軸", @@ -695,7 +714,7 @@ "avgCostPerMillionTokens": "平均每百萬 Token 成本", "unknownModel": "未知", "successRateUnavailable": "不適用", - "successRateBasisDisclosure": "在 redirected 計費模型模式下,一列可能合併多個原始模型,因此這裡不顯示成功率以避免口徑誤導。" + "successRateBasisDisclosure": "所選時間範圍內該模型沒有可計數的成功或失敗結果,因此無法計算成功率。" }, "expandModelStats": "展開模型詳情", "collapseModelStats": "收起模型詳情", diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 55eee4b7f..295f6374c 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -6,6 +6,7 @@ import { getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; import { messageRequest, usageLedger } from "@/drizzle/schema"; import { getSession } from "@/lib/auth"; +import type { RequestCacheMetricAvailability } from "@/lib/cache-effectiveness/request-metrics"; import { lookupIp } from "@/lib/ip-geo/client"; import { logger } from "@/lib/logger"; import { resolveKeyConcurrentSessionLimit } from "@/lib/rate-limit/concurrent-session-limit"; @@ -251,6 +252,14 @@ export interface MyUsageLogEntry { cacheCreation5mInputTokens: number | null; cacheCreation1hInputTokens: number | null; cacheTtlApplied: string | null; + theoreticalCacheTokens: number | null; + cacheScoreEligible: boolean | null; + cacheScoreExcludedReason: string | null; + cacheInputTotal: number; + actualCacheRate: number | null; + theoreticalCacheRate: number | null; + requestCacheCoefficientBp: number | null; + requestCacheMetricAvailability: RequestCacheMetricAvailability; } export interface MyUsageLogsBatchResult { @@ -656,6 +665,14 @@ function mapMyUsageLogEntries( cacheCreation5mInputTokens: log.cacheCreation5mInputTokens ?? null, cacheCreation1hInputTokens: log.cacheCreation1hInputTokens ?? null, cacheTtlApplied: log.cacheTtlApplied ?? null, + theoreticalCacheTokens: log.theoreticalCacheTokens ?? null, + cacheScoreEligible: log.cacheScoreEligible ?? null, + cacheScoreExcludedReason: log.cacheScoreExcludedReason ?? null, + cacheInputTotal: log.cacheInputTotal, + actualCacheRate: log.actualCacheRate, + theoreticalCacheRate: log.theoreticalCacheRate, + requestCacheCoefficientBp: log.requestCacheCoefficientBp, + requestCacheMetricAvailability: log.requestCacheMetricAvailability, }; }); } diff --git a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx index 236c232b9..060d519f5 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx +++ b/src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx @@ -81,7 +81,11 @@ type AnyEntry = | ModelEntry; function renderSuccessRateCell( - row: { successRate: number | null; basisDisclosureRequired?: boolean }, + row: { + successRate: number | null; + basisDisclosureRequired?: boolean; + successRateUnavailableReason?: "no_countable_outcomes"; + }, t: ReturnType ) { const display = getSuccessRateCellDisplay(row, t); diff --git a/src/app/[locale]/dashboard/leaderboard/_components/success-rate-display.ts b/src/app/[locale]/dashboard/leaderboard/_components/success-rate-display.ts index 2ebe620f4..22928cafb 100644 --- a/src/app/[locale]/dashboard/leaderboard/_components/success-rate-display.ts +++ b/src/app/[locale]/dashboard/leaderboard/_components/success-rate-display.ts @@ -1,6 +1,7 @@ export interface SuccessRateDisplayRow { successRate: number | null; basisDisclosureRequired?: boolean; + successRateUnavailableReason?: "no_countable_outcomes"; } export interface SuccessRateDisplayValue { @@ -21,6 +22,9 @@ export function getSuccessRateCellDisplay( return { label: t("columns.successRateUnavailable"), - title: row.basisDisclosureRequired ? t("columns.successRateBasisDisclosure") : undefined, + title: + row.successRateUnavailableReason === "no_countable_outcomes" + ? t("columns.successRateBasisDisclosure") + : undefined, }; } diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 10e9edddb..feecf2ed5 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -210,6 +210,7 @@ const messages = { endpoint: "Endpoint", }, details: { + ...dashboardMessages.logs.details, routingTrace: dashboardMessages.logs.details.routingTrace, modelAudit: dashboardMessages.logs.details.modelAudit, title: "Request Details", @@ -289,6 +290,8 @@ const messages = { metadata: { noMetadata: "No metadata", sessionInfo: "Session Info", + sessionId: "Session ID", + prefixId: "Prefix ID", clientInfo: "Client Info", billingInfo: "Billing Info", technicalTimeline: "Technical Timeline", @@ -300,6 +303,7 @@ const messages = { tooltip: "Thinking effort in the Codex request (reasoning.effort), shown verbatim.", }, logicTrace: { + ...dashboardMessages.logs.details.logicTrace, title: "Decision Chain", singleRouteSelectionTitle: "Provider selection under single-route protection", noDecisionData: "No decision data", @@ -441,6 +445,7 @@ describe("error-details-dialog layout", () => { providerChain={null} sessionId="pfx:scope:root" sourceSessionId="physical-a" + sessionIdentityKind="prefix_affinity" requestSequence={3} /> ); @@ -463,6 +468,7 @@ describe("error-details-dialog layout", () => { providerChain={null} sessionId="pfx:scope:root" sourceSessionId="physical-a" + sessionIdentityKind="prefix_affinity" requestSequence={3} /> ); @@ -474,6 +480,129 @@ describe("error-details-dialog layout", () => { expect(container.querySelector('a[href*="sourceSessionId=physical-a"]')).toBeTruthy(); expect(container.querySelector('a[href*="seq=3"]')).toBeTruthy(); + expect(container.querySelector('a[href*="sessionId=pfx%3Ascope%3Aroot"]')).toBeTruthy(); + expect(container.querySelector('a[href*="sessionId=physical-a"]')).toBeTruthy(); + unmount(); + }); + + test("links both Prefix ID and physical Session ID from the Logic Trace", async () => { + const { container, unmount } = renderClientWithIntl( + + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const reuseStep = Array.from(container.querySelectorAll('[data-slot="step-card"]')).find( + (element) => element.textContent?.includes("Session Reuse Selection") + ); + expect(reuseStep).toBeTruthy(); + + expect( + container.querySelector('a[href*="sessionId=pfx%3Ascope%2Froot%3Fbranch%23frag"]') + ).toBeTruthy(); + expect(container.querySelector('a[href*="sessionId=physical-session"]')).toBeTruthy(); + expect(container.textContent).toContain("Prefix ID"); + expect(container.textContent).toContain("Session ID"); + unmount(); + }); + + test("shows cache metrics and encoded identity links in the Prefix Affinity step", async () => { + const { container, unmount } = renderClientWithIntl( + + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const affinityStep = Array.from(container.querySelectorAll('[data-slot="step-card"]')).find( + (element) => element.textContent?.includes("affinity-provider") + ); + expect(affinityStep).toBeTruthy(); + expect( + affinityStep?.querySelector('a[href*="sessionId=pfx%3Ascope%2Froot%3Fbranch%23frag"]') + ).toBeTruthy(); + expect(affinityStep?.querySelector('a[href*="sessionId=physical-session"]')).toBeTruthy(); + expect(affinityStep?.textContent).toContain("Cache Performance"); + expect(affinityStep?.textContent).toContain("20.0%"); + expect(affinityStep?.textContent).toContain("40.0%"); + expect(affinityStep?.textContent).toContain("0.50"); + unmount(); + }); + + test("encodes prefix identities in the Session detail path", async () => { + hasSessionMessagesMock.mockResolvedValue({ ok: true, data: true }); + const { container, unmount } = renderClientWithIntl( + + ); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect( + container.querySelector( + 'a[href*="/dashboard/sessions/pfx%3Ascope%2Froot%3Fbranch%23frag/messages"]' + ) + ).toBeTruthy(); unmount(); }); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/CachePerformance.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/CachePerformance.test.tsx new file mode 100644 index 000000000..33b36fd48 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/CachePerformance.test.tsx @@ -0,0 +1,81 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test, vi } from "vitest"; +import { CachePerformance } from "./CachePerformance"; + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, + TooltipContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + TooltipProvider: ({ children }: { children: React.ReactNode }) => children, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => children, +})); + +const messages = { + dashboard: { + logs: { + details: { + cachePerformance: { + title: "Cache Performance", + actualRate: "Actual Cache Rate", + theoreticalRate: "Theoretical Cache Rate", + coefficient: "Request Cache Coefficient", + rawCoefficient: "Raw coefficient", + estimateNote: "Estimated", + availability: { + available: "Available", + no_input: "No input", + no_affinity_key: "No Prefix ID", + attempt_failed: "Attempt failed", + not_observable: "Not observable", + stream_truncated: "Stream truncated", + not_recorded: "Not recorded", + }, + }, + }, + }, + }, +}; + +function renderMetrics(props: React.ComponentProps) { + return renderToStaticMarkup( + + + + ); +} + +describe("CachePerformance", () => { + test("renders all three request-level metrics and token provenance", () => { + const html = renderMetrics({ + actualCacheRate: 0.25, + theoreticalCacheRate: 0.5, + requestCacheCoefficientBp: 5000, + requestCacheMetricAvailability: "available", + cacheInputTotal: 200, + cacheReadInputTokens: 50, + theoreticalCacheTokens: 100, + }); + + expect(html).toContain("25.0%"); + expect(html).toContain("50.0%"); + expect(html).toContain("0.50"); + expect(html).toContain("50 / 200"); + expect(html).toContain("100 / 200"); + }); + + test("renders unavailable values and the reason instead of zero", () => { + const html = renderMetrics({ + actualCacheRate: 0, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "no_affinity_key", + cacheInputTotal: 100, + cacheReadInputTokens: 0, + theoreticalCacheTokens: null, + }); + + expect(html).toContain("0.0%"); + expect(html.match(/–/g)?.length).toBeGreaterThanOrEqual(2); + expect(html).toContain("No Prefix ID"); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/CachePerformance.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/CachePerformance.tsx new file mode 100644 index 000000000..d9bd066e8 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/CachePerformance.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { DatabaseZap } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import type { RequestCacheMetricAvailability } from "@/lib/cache-effectiveness/request-metrics"; +import { cn, formatTokenAmount } from "@/lib/utils"; + +interface CachePerformanceProps { + actualCacheRate: number | null | undefined; + theoreticalCacheRate: number | null | undefined; + requestCacheCoefficientBp: number | null | undefined; + requestCacheMetricAvailability: RequestCacheMetricAvailability | undefined; + cacheInputTotal?: number | null; + cacheReadInputTokens?: number | null; + theoreticalCacheTokens?: number | null; + compact?: boolean; +} + +function formatRate(rate: number | null | undefined): string { + return typeof rate === "number" ? `${(rate * 100).toFixed(1)}%` : "–"; +} + +function formatCoefficient(bp: number | null | undefined): string { + return typeof bp === "number" ? (bp / 10000).toFixed(2) : "–"; +} + +export function CachePerformance({ + actualCacheRate, + theoreticalCacheRate, + requestCacheCoefficientBp, + requestCacheMetricAvailability, + cacheInputTotal, + cacheReadInputTokens, + theoreticalCacheTokens, + compact = false, +}: CachePerformanceProps) { + const t = useTranslations("dashboard.logs.details.cachePerformance"); + const availability = requestCacheMetricAvailability ?? "not_recorded"; + const availabilityLabel = t(`availability.${availability}`); + const values = [ + { + label: t("actualRate"), + value: formatRate(actualCacheRate), + tokenValue: + cacheInputTotal != null && cacheReadInputTokens != null + ? `${formatTokenAmount(cacheReadInputTokens)} / ${formatTokenAmount(cacheInputTotal)}` + : null, + }, + { + label: t("theoreticalRate"), + value: formatRate(theoreticalCacheRate), + tokenValue: + cacheInputTotal != null && theoreticalCacheTokens != null + ? `${formatTokenAmount(theoreticalCacheTokens)} / ${formatTokenAmount(cacheInputTotal)}` + : null, + }, + { + label: t("coefficient"), + value: formatCoefficient(requestCacheCoefficientBp), + tokenValue: requestCacheCoefficientBp == null ? availabilityLabel : t("rawCoefficient"), + }, + ]; + + return ( +
+
+ +

{t("title")}

+
+
+ {values.map((item) => ( + + + +
+

{item.label}

+

+ {item.value} +

+
+
+ +

{item.tokenValue ?? availabilityLabel}

+ {!compact &&

{t("estimateNote")}

} +
+
+
+ ))} +
+ {!compact &&

{availabilityLabel}

} +
+ ); +} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx index 0479ec22e..ceb29b8c5 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/LogicTraceTab.tsx @@ -24,6 +24,7 @@ import { useTranslations } from "next-intl"; import { useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Link } from "@/i18n/routing"; import { getSessionOriginChain } from "@/lib/api-client/v1/actions/session-origin-chain"; import { cn, formatTokenAmount } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/currency"; @@ -32,7 +33,9 @@ import { formatProbability, formatProviderTimeline } from "@/lib/utils/provider- import type { ProviderChainItem } from "@/types/message"; import { normalizeRoutingTrace } from "@/types/routing-trace"; import { type LogicTraceTabProps, parseBlockedReason } from "../types"; +import { CachePerformance } from "./CachePerformance"; import { DiscoveryTraceView, RoutingModeBanner } from "./DiscoveryTraceView"; +import { buildLogsFilterHref } from "./logs-filter-href"; import { StepCard, type StepStatus } from "./StepCard"; function getRequestStatus(item: ProviderChainItem): StepStatus { @@ -74,6 +77,7 @@ export function LogicTraceTab({ routingTrace, sessionId, sourceSessionId, + sessionIdentityKind, blockedBy, blockedReason, isReplay, @@ -85,6 +89,12 @@ export function LogicTraceTab({ outputTokens, cacheCreationInputTokens, cacheReadInputTokens, + cacheInputTotal, + actualCacheRate, + theoreticalCacheRate, + theoreticalCacheTokens, + requestCacheCoefficientBp, + requestCacheMetricAvailability, initialExpandedChainIndex, }: LogicTraceTabProps) { const t = useTranslations("dashboard.logs.details"); @@ -402,11 +412,28 @@ export function LogicTraceTab({ {sessionReuseContext?.sessionId && (
- {t("logicTrace.sessionIdLabel")}: + {sessionIdentityKind === "prefix_affinity" + ? t("metadata.prefixId") + : t("logicTrace.sessionIdLabel")} + : - - {sessionReuseContext.sessionId.slice(0, 8)}... - + + {sessionReuseContext.sessionId} + +
+ )} + {sourceSessionId && sourceSessionId !== sessionReuseContext?.sessionId && ( +
+ {t("metadata.sessionId")}: + + {sourceSessionId} +
)} {requestSequence !== undefined && requestSequence !== null && ( @@ -462,12 +489,17 @@ export function LogicTraceTab({ - {/* Cache Optimization Hint */}
-
- - {t("logicTrace.cacheOptimizationHint")} -
+
} @@ -1027,6 +1059,39 @@ export function LogicTraceTab({ {tChain("reasons.affinity_hit")} + {(sessionId || sourceSessionId) && ( +
+ {sessionId && ( +
+ + {sessionIdentityKind === "prefix_affinity" + ? t("metadata.prefixId") + : t("metadata.sessionId")} + : + + + {sessionId} + +
+ )} + {sourceSessionId && sourceSessionId !== sessionId && ( +
+ + {t("metadata.sessionId")}: + + + {sourceSessionId} + +
+ )} +
+ )}
{item.affinity?.matchedDepth != null && (
@@ -1055,6 +1120,18 @@ export function LogicTraceTab({
)}
+
+ +
)} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index 2f2902b1b..c9c45a2cd 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -39,6 +39,8 @@ import { extractThinkingEffortInfo } from "@/lib/utils/thinking-effort"; import { getFake200ReasonKey } from "../../fake200-reason"; import { Fake200RetryTooltip } from "../../fake200-retry-tooltip"; import { isInProgressStatus, isSuccessStatus, type SummaryTabProps } from "../types"; +import { CachePerformance } from "./CachePerformance"; +import { buildLogsFilterHref } from "./logs-filter-href"; export function SummaryTab({ statusCode, @@ -54,6 +56,12 @@ export function SummaryTab({ cacheCreation1hInputTokens, cacheReadInputTokens, cacheTtlApplied, + theoreticalCacheTokens, + cacheInputTotal, + actualCacheRate, + theoreticalCacheRate, + requestCacheCoefficientBp, + requestCacheMetricAvailability, swapCacheTtlApplied, costUsd, costMultiplier, @@ -67,6 +75,7 @@ export function SummaryTab({ firstByteMs, sessionId, sourceSessionId, + sessionIdentityKind, requestSequence, userAgent, clientIp, @@ -97,8 +106,22 @@ export function SummaryTab({ sessionRequestParams.set("sourceSessionId", sourceSessionId); } const sessionMessagesHref = sessionId - ? `/dashboard/sessions/${sessionId}/messages${sessionRequestParams.size > 0 ? `?${sessionRequestParams.toString()}` : ""}` + ? `/dashboard/sessions/${encodeURIComponent(sessionId)}/messages${sessionRequestParams.size > 0 ? `?${sessionRequestParams.toString()}` : ""}` : ""; + const identityRows = [ + sessionId + ? { + label: + sessionIdentityKind === "prefix_affinity" + ? t("metadata.prefixId") + : t("metadata.sessionId"), + value: sessionId, + } + : null, + sourceSessionId && sourceSessionId !== sessionId + ? { label: t("metadata.sessionId"), value: sourceSessionId } + : null, + ].filter((item): item is { label: string; value: string } => item !== null); const modelAudit = resolveModelAuditDisplay({ originalModel: originalModel ?? null, model: currentModel ?? null, @@ -301,25 +324,43 @@ export function SummaryTab({ )} + + {/* Session Info */} - {(sessionId || effortDisplay) && ( + {(identityRows.length > 0 || effortDisplay) && (

{t("metadata.sessionInfo")}

- {sessionId && ( -
+ {identityRows.map((identity) => ( +
- {sessionId} - {requestSequence && ( + + {identity.label}: + + + {identity.value} + + {identity.value === sessionId && requestSequence && ( #{requestSequence} )}
- {hasMessages && !checkingMessages && ( + {identity.value === sessionId && hasMessages && !checkingMessages && (
- )} + ))} {effortDisplay && (
diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/index.ts b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/index.ts index 67223ae82..ef161c110 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/index.ts +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/index.ts @@ -1,3 +1,4 @@ +export { CachePerformance } from "./CachePerformance"; export { DiscoveryTraceView, RoutingModeBanner } from "./DiscoveryTraceView"; export { LatencyBreakdownBar } from "./LatencyBreakdownBar"; export { LogicTraceTab } from "./LogicTraceTab"; diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/logs-filter-href.ts b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/logs-filter-href.ts new file mode 100644 index 000000000..1d44935f0 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/logs-filter-href.ts @@ -0,0 +1,5 @@ +export function buildLogsFilterHref(identity: string): string { + const query = new URLSearchParams(); + query.set("sessionId", identity); + return `/dashboard/logs?${query.toString()}`; +} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx index cde88e871..3937cb57c 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/index.tsx @@ -23,6 +23,7 @@ interface ErrorDetailsDialogProps { routingTrace?: RoutingTraceV1 | null; sessionId: string | null; sourceSessionId?: string | null; + sessionIdentityKind?: "session_id" | "prefix_affinity" | null; requestSequence?: number | null; blockedBy?: string | null; blockedReason?: string | null; @@ -44,6 +45,14 @@ interface ErrorDetailsDialogProps { cacheCreation1hInputTokens?: number | null; cacheReadInputTokens?: number | null; cacheTtlApplied?: string | null; + theoreticalCacheTokens?: number | null; + cacheScoreEligible?: boolean | null; + cacheScoreExcludedReason?: string | null; + cacheInputTotal?: number | null; + actualCacheRate?: number | null; + theoreticalCacheRate?: number | null; + requestCacheCoefficientBp?: number | null; + requestCacheMetricAvailability?: import("@/lib/cache-effectiveness/request-metrics").RequestCacheMetricAvailability; swapCacheTtlApplied?: boolean | null; costUsd?: string | null; costMultiplier?: string | null; @@ -72,6 +81,7 @@ export function ErrorDetailsDialog({ routingTrace, sessionId, sourceSessionId, + sessionIdentityKind, requestSequence, blockedBy, blockedReason, @@ -93,6 +103,14 @@ export function ErrorDetailsDialog({ cacheCreation1hInputTokens, cacheReadInputTokens, cacheTtlApplied, + theoreticalCacheTokens, + cacheScoreEligible, + cacheScoreExcludedReason, + cacheInputTotal, + actualCacheRate, + theoreticalCacheRate, + requestCacheCoefficientBp, + requestCacheMetricAvailability, swapCacheTtlApplied, costUsd, costMultiplier, @@ -226,6 +244,7 @@ export function ErrorDetailsDialog({ routingTrace, sessionId, sourceSessionId, + sessionIdentityKind, requestSequence, blockedBy, blockedReason, @@ -247,6 +266,14 @@ export function ErrorDetailsDialog({ cacheCreation1hInputTokens, cacheReadInputTokens, cacheTtlApplied, + theoreticalCacheTokens, + cacheScoreEligible, + cacheScoreExcludedReason, + cacheInputTotal, + actualCacheRate, + theoreticalCacheRate, + requestCacheCoefficientBp, + requestCacheMetricAvailability, swapCacheTtlApplied, costUsd, costMultiplier, diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts index fda32610c..5012c3560 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/types.ts @@ -1,3 +1,4 @@ +import type { RequestCacheMetricAvailability } from "@/lib/cache-effectiveness/request-metrics"; import type { HedgeLoserBilling, StoredCostBreakdown } from "@/types/cost-breakdown"; import type { ProviderChainItem } from "@/types/message"; import type { RoutingTraceV1 } from "@/types/routing-trace"; @@ -20,6 +21,8 @@ export interface TabSharedProps { sessionId: string | null; /** Physical Session source for request-scoped readback */ sourceSessionId?: string | null; + /** Canonical identity kind used to distinguish Prefix ID from Session ID */ + sessionIdentityKind?: "session_id" | "prefix_affinity" | null; /** Request sequence number within session */ requestSequence?: number | null; /** Block type (e.g., "sensitive_word", "warmup") */ @@ -62,6 +65,18 @@ export interface TabSharedProps { cacheReadInputTokens?: number | null; /** Cache TTL applied */ cacheTtlApplied?: string | null; + /** Theoretical longest-prefix cache token estimate */ + theoreticalCacheTokens?: number | null; + /** Whether the request is eligible for the cache-effectiveness window */ + cacheScoreEligible?: boolean | null; + /** Exclusion reason when the request is not eligible */ + cacheScoreExcludedReason?: string | null; + /** Input-side cache denominator */ + cacheInputTotal?: number | null; + actualCacheRate?: number | null; + theoreticalCacheRate?: number | null; + requestCacheCoefficientBp?: number | null; + requestCacheMetricAvailability?: RequestCacheMetricAvailability; /** Whether swap cache TTL billing was applied */ swapCacheTtlApplied?: boolean | null; /** Total cost in USD */ diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index 80a3fa6e8..9b9d90c58 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -72,6 +72,7 @@ function makeLog(overrides: Partial): UsageLogRow { createdAt: new Date(), sessionId: null, sourceSessionId: null, + sessionIdentityKind: null, requestSequence: null, userName: "u", keyName: "k", @@ -88,6 +89,14 @@ function makeLog(overrides: Partial): UsageLogRow { cacheCreation5mInputTokens: 0, cacheCreation1hInputTokens: 0, cacheTtlApplied: null, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + cacheInputTotal: 1, + actualCacheRate: 0, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "not_recorded", totalTokens: 2, costUsd: "0.01", costMultiplier: null, diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index 9706fcd66..8c7f6ed71 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -663,6 +663,7 @@ export function UsageLogsTable({ routingTrace={log.routingTrace} sessionId={log.sessionId} sourceSessionId={log.sourceSessionId} + sessionIdentityKind={log.sessionIdentityKind} requestSequence={log.requestSequence} blockedBy={log.blockedBy} blockedReason={log.blockedReason} @@ -684,6 +685,14 @@ export function UsageLogsTable({ cacheCreation1hInputTokens={log.cacheCreation1hInputTokens} cacheReadInputTokens={log.cacheReadInputTokens} cacheTtlApplied={log.cacheTtlApplied} + theoreticalCacheTokens={log.theoreticalCacheTokens} + cacheScoreEligible={log.cacheScoreEligible} + cacheScoreExcludedReason={log.cacheScoreExcludedReason} + cacheInputTotal={log.cacheInputTotal} + actualCacheRate={log.actualCacheRate} + theoreticalCacheRate={log.theoreticalCacheRate} + requestCacheCoefficientBp={log.requestCacheCoefficientBp} + requestCacheMetricAvailability={log.requestCacheMetricAvailability} swapCacheTtlApplied={log.swapCacheTtlApplied} costUsd={log.costUsd} costMultiplier={log.costMultiplier} diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx index dbc574cf3..199221580 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -144,6 +144,7 @@ function makeLog(overrides: Partial): UsageLogRow { createdAt: new Date(), sessionId: null, sourceSessionId: null, + sessionIdentityKind: null, requestSequence: null, userName: "u", keyName: "k", @@ -160,6 +161,14 @@ function makeLog(overrides: Partial): UsageLogRow { cacheCreation5mInputTokens: 0, cacheCreation1hInputTokens: 0, cacheTtlApplied: null, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + cacheInputTotal: 1, + actualCacheRate: 0, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "not_recorded", totalTokens: 2, costUsd: "0.01", costMultiplier: null, diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 49999dca6..7e6a78c0e 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -1316,6 +1316,7 @@ export function VirtualizedLogsTable({ routingTrace={log.routingTrace} sessionId={log.sessionId} sourceSessionId={log.sourceSessionId} + sessionIdentityKind={log.sessionIdentityKind} requestSequence={log.requestSequence} blockedBy={log.blockedBy} blockedReason={log.blockedReason} @@ -1337,6 +1338,14 @@ export function VirtualizedLogsTable({ cacheCreation1hInputTokens={log.cacheCreation1hInputTokens} cacheReadInputTokens={log.cacheReadInputTokens} cacheTtlApplied={log.cacheTtlApplied} + theoreticalCacheTokens={log.theoreticalCacheTokens} + cacheScoreEligible={log.cacheScoreEligible} + cacheScoreExcludedReason={log.cacheScoreExcludedReason} + cacheInputTotal={log.cacheInputTotal} + actualCacheRate={log.actualCacheRate} + theoreticalCacheRate={log.theoreticalCacheRate} + requestCacheCoefficientBp={log.requestCacheCoefficientBp} + requestCacheMetricAvailability={log.requestCacheMetricAvailability} swapCacheTtlApplied={log.swapCacheTtlApplied} costUsd={log.costUsd} costMultiplier={log.costMultiplier} diff --git a/src/app/[locale]/my-usage/_components/usage-logs-table.test.tsx b/src/app/[locale]/my-usage/_components/usage-logs-table.test.tsx index 3af073d4b..35678de90 100644 --- a/src/app/[locale]/my-usage/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/my-usage/_components/usage-logs-table.test.tsx @@ -82,6 +82,14 @@ function makeLog(overrides: Partial = {}): MyUsageLogEntry { cacheCreation5mInputTokens: 0, cacheCreation1hInputTokens: 0, cacheTtlApplied: null, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + cacheInputTotal: 30, + actualCacheRate: 0, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "not_recorded", ...overrides, }; } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 8cebeaab8..fd2a896ac 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -275,7 +275,7 @@ async function startCacheEffectivenessScheduler(): Promise { await aggregateCacheEffectiveness(); })().catch((error) => { logger.warn("[Instrumentation] Cache effectiveness aggregation tick failed", { - error: error instanceof Error ? error.message : String(error), + ...describeSchedulerError(error), }); }); }, intervalMs); @@ -286,11 +286,37 @@ async function startCacheEffectivenessScheduler(): Promise { }); } catch (error) { logger.warn("[Instrumentation] Cache effectiveness scheduler init failed", { - error: error instanceof Error ? error.message : String(error), + ...describeSchedulerError(error), }); } } +export function describeSchedulerError(error: unknown): { + error: string; + errorName?: string; + errorCause?: string; + errorCauseName?: string; + errorCauseCode?: string | number; +} { + const outerError = error instanceof Error ? error : undefined; + const cause = outerError?.cause; + const causeObject = cause && typeof cause === "object" ? cause : undefined; + const causeCode = causeObject && "code" in causeObject ? causeObject.code : undefined; + return { + error: outerError?.message ?? String(error), + errorName: outerError?.name, + errorCause: + cause instanceof Error + ? cause.message.slice(0, 500) + : cause + ? String(cause).slice(0, 500) + : undefined, + errorCauseName: cause instanceof Error ? cause.name : undefined, + errorCauseCode: + typeof causeCode === "string" || typeof causeCode === "number" ? causeCode : undefined, + }; +} + /** * F2:Replay PG 持久层过期行清理(每 10 分钟,ENABLE_REQUEST_REPLAY 开启时)。 * 写入路径已有机会式扫尾,此任务兜底低流量期无写入的场景。 diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index 1ac16d719..23c5c7190 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -35657,7 +35657,7 @@ export interface operations { page?: number; /** @description Offset page size. */ pageSize?: number; - /** @description Session id filter. */ + /** @description Exact Session ID or Prefix ID filter. */ sessionId?: string; /** @description User id filter. */ userId?: number | null; @@ -35864,7 +35864,7 @@ export interface operations { page?: number; /** @description Offset page size. */ pageSize?: number; - /** @description Session id filter. */ + /** @description Exact Session ID or Prefix ID filter. */ sessionId?: string; /** @description User id filter. */ userId?: number | null; @@ -36936,7 +36936,7 @@ export interface operations { requestBody?: { content: { "application/json": { - /** @description Session id filter. */ + /** @description Exact Session ID or Prefix ID filter. */ sessionId?: string; /** @description User id filter. */ userId?: number | null; @@ -38025,7 +38025,7 @@ export interface operations { startTime?: number | null; /** @description End timestamp in milliseconds. */ endTime?: number | null; - /** @description Session id filter. */ + /** @description Exact Session ID or Prefix ID filter. */ sessionId?: string; /** @description Model filter. */ model?: string; @@ -38228,7 +38228,7 @@ export interface operations { startTime?: number | null; /** @description End timestamp in milliseconds. */ endTime?: number | null; - /** @description Session id filter. */ + /** @description Exact Session ID or Prefix ID filter. */ sessionId?: string; /** @description Model filter. */ model?: string; diff --git a/src/lib/api/v1/schemas/me.ts b/src/lib/api/v1/schemas/me.ts index 83643be33..4880d1240 100644 --- a/src/lib/api/v1/schemas/me.ts +++ b/src/lib/api/v1/schemas/me.ts @@ -16,7 +16,7 @@ export const MeUsageLogsQuerySchema = z.object({ endDate: z.string().optional().describe("End date in YYYY-MM-DD."), startTime: NumberQuerySchema.describe("Start timestamp in milliseconds."), endTime: NumberQuerySchema.describe("End timestamp in milliseconds."), - sessionId: z.string().optional().describe("Session id filter."), + sessionId: z.string().optional().describe("Exact Session ID or Prefix ID filter."), model: z.string().optional().describe("Model filter."), actualResponseModelMismatch: BooleanQuerySchema.describe( "Only include records whose requested model differs from the actual response model." diff --git a/src/lib/api/v1/schemas/usage-logs.ts b/src/lib/api/v1/schemas/usage-logs.ts index 253bec9c6..2b01bc88a 100644 --- a/src/lib/api/v1/schemas/usage-logs.ts +++ b/src/lib/api/v1/schemas/usage-logs.ts @@ -17,7 +17,7 @@ export const UsageLogsQuerySchema = z.object({ .optional() .describe("Offset page number for legacy listing."), pageSize: z.coerce.number().int().min(1).max(100).optional().describe("Offset page size."), - sessionId: z.string().optional().describe("Session id filter."), + sessionId: z.string().optional().describe("Exact Session ID or Prefix ID filter."), userId: NumberQuerySchema.describe("User id filter."), keyId: NumberQuerySchema.describe("Key id filter."), providerId: NumberQuerySchema.describe("Provider id filter."), diff --git a/src/lib/cache-effectiveness/request-metrics.ts b/src/lib/cache-effectiveness/request-metrics.ts new file mode 100644 index 000000000..cce466684 --- /dev/null +++ b/src/lib/cache-effectiveness/request-metrics.ts @@ -0,0 +1,143 @@ +export type RequestCacheMetricAvailability = + | "available" + | "no_input" + | "no_affinity_key" + | "attempt_failed" + | "not_observable" + | "stream_truncated" + | "not_recorded"; + +export interface RequestCacheMetricsInput { + inputTokens: number | null | undefined; + cacheCreationInputTokens: number | null | undefined; + cacheReadInputTokens: number | null | undefined; + theoreticalCacheTokens: number | null | undefined; + cacheScoreEligible: boolean | null | undefined; + cacheScoreExcludedReason: string | null | undefined; + sessionIdentityKind: "session_id" | "prefix_affinity" | null | undefined; +} + +export interface RequestCacheMetrics { + cacheInputTotal: number; + actualCacheRate: number | null; + theoreticalCacheRate: number | null; + requestCacheCoefficientBp: number | null; + requestCacheMetricAvailability: RequestCacheMetricAvailability; +} + +const EXCLUDED_REASONS = new Set([ + "attempt_failed", + "not_observable", + "stream_truncated", +]); + +function finiteNonNegative(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function clamp01(value: number): number { + return Math.min(Math.max(value, 0), 1); +} + +function normalizeExcludedReason( + reason: string | null | undefined +): RequestCacheMetricAvailability | null { + if (!reason) return null; + if (reason === "attempt_failed" || reason === "not_observable" || reason === "stream_truncated") { + return reason; + } + if (reason === "no_affinity_key") return "no_affinity_key"; + return null; +} + +/** + * Derive the three request-level cache metrics from persisted token provenance. + * The coefficient is the raw single-request ratio; provider-window confidence is + * intentionally not applied here. + */ +export function deriveRequestCacheMetrics(input: RequestCacheMetricsInput): RequestCacheMetrics { + const cacheInputTotal = + finiteNonNegative(input.inputTokens) + + finiteNonNegative(input.cacheCreationInputTokens) + + finiteNonNegative(input.cacheReadInputTokens); + const actualCacheRate = + cacheInputTotal > 0 + ? clamp01(finiteNonNegative(input.cacheReadInputTokens) / cacheInputTotal) + : null; + + const hasAnyF3bField = + input.theoreticalCacheTokens != null || + input.cacheScoreEligible != null || + input.cacheScoreExcludedReason != null; + const theoreticalTokens = + input.theoreticalCacheTokens == null ? null : finiteNonNegative(input.theoreticalCacheTokens); + const normalizedReason = normalizeExcludedReason(input.cacheScoreExcludedReason); + + if (!hasAnyF3bField) { + return { + cacheInputTotal, + actualCacheRate, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "not_recorded", + }; + } + + if (normalizedReason) { + if (cacheInputTotal <= 0) { + return { + cacheInputTotal, + actualCacheRate: null, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: normalizedReason, + }; + } + + const theoreticalCacheRate = + theoreticalTokens != null ? clamp01(theoreticalTokens / cacheInputTotal) : null; + return { + cacheInputTotal, + actualCacheRate, + theoreticalCacheRate, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: normalizedReason, + }; + } + + if (cacheInputTotal <= 0) { + return { + cacheInputTotal, + actualCacheRate: null, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "no_input", + }; + } + + const availability = theoreticalTokens == null ? "no_affinity_key" : "available"; + const theoreticalCacheRate = + theoreticalTokens != null ? clamp01(theoreticalTokens / cacheInputTotal) : null; + const coefficientAvailable = + theoreticalTokens != null && + theoreticalTokens > 0 && + input.cacheScoreEligible !== false && + !EXCLUDED_REASONS.has(availability); + const requestCacheCoefficientBp = coefficientAvailable + ? Math.min( + Math.max( + Math.trunc((finiteNonNegative(input.cacheReadInputTokens) * 10000) / theoreticalTokens), + 0 + ), + 10000 + ) + : null; + + return { + cacheInputTotal, + actualCacheRate, + theoreticalCacheRate, + requestCacheCoefficientBp, + requestCacheMetricAvailability: availability, + }; +} diff --git a/src/lib/cache-effectiveness/service.ts b/src/lib/cache-effectiveness/service.ts index 434336b18..fc821919f 100644 --- a/src/lib/cache-effectiveness/service.ts +++ b/src/lib/cache-effectiveness/service.ts @@ -80,6 +80,8 @@ export async function aggregateCacheEffectiveness( } signal?.throwIfAborted(); + const windowStartIso = windowStart.toISOString(); + const windowEndIso = windowEnd.toISOString(); // 单条 SQL 完成分组聚合 + 定点数学 + 写入(全整数运算) const inserted = await tx.execute(sql` WITH grouped AS ( @@ -95,8 +97,8 @@ export async function aggregateCacheEffectiveness( WHERE mr.cache_compatibility_key IS NOT NULL AND mr.deleted_at IS NULL AND mr.provider_id > 0 - AND mr.created_at >= ${windowStart} - AND mr.created_at < ${windowEnd} + AND mr.created_at >= ${windowStartIso}::timestamptz + AND mr.created_at < ${windowEndIso}::timestamptz GROUP BY mr.provider_id, COALESCE(mr.model, ''), COALESCE(mr.cache_ttl_bucket, '5m') ), scored AS ( @@ -104,7 +106,7 @@ export async function aggregateCacheEffectiveness( g.*, CASE WHEN g.theoretical_tokens > 0 - THEN LEAST((g.observed_tokens * 10000) / g.theoretical_tokens, 10000)::int + THEN GREATEST(LEAST((g.observed_tokens * 10000) / g.theoretical_tokens, 10000), 0)::int ELSE 0 END AS raw_bp, CASE @@ -129,8 +131,8 @@ export async function aggregateCacheEffectiveness( s.provider_id, s.model, s.cache_ttl_bucket, - ${windowStart}, - ${windowEnd}, + ${windowStartIso}::timestamptz, + ${windowEndIso}::timestamptz, s.sample_count, s.eligible_count, s.theoretical_tokens, diff --git a/src/lib/redis/leaderboard-cache.ts b/src/lib/redis/leaderboard-cache.ts index a4433c68c..bb23f2ab7 100644 --- a/src/lib/redis/leaderboard-cache.ts +++ b/src/lib/redis/leaderboard-cache.ts @@ -67,8 +67,9 @@ export interface LeaderboardFilters { * 缓存值 shape 版本:条目结构变更时递增,避免 60s TTL 内新旧 payload 混用。 * v2: provider / providerCacheHitRate 条目新增 cacheCoefficientBp * v3: leaderboard latency fields use avgTtftMs instead of avgTtfbMs + * v4: provider model breakdown entries include cacheCoefficientBp */ -const CACHE_SHAPE_VERSION = "v3"; +const CACHE_SHAPE_VERSION = "v4"; /** * 构建缓存键 @@ -104,22 +105,22 @@ function buildCacheKey( const prefix = `leaderboard:${CACHE_SHAPE_VERSION}:${scope}`; if (period === "custom" && dateRange) { - // leaderboard:v3:{scope}:custom:2025-01-01_2025-01-15:USD + // leaderboard:v4:{scope}:custom:2025-01-01_2025-01-15:USD return `${prefix}:custom:${dateRange.startDate}_${dateRange.endDate}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "daily") { - // leaderboard:v3:{scope}:daily:2025-01-15:USD + // leaderboard:v4:{scope}:daily:2025-01-15:USD const dateStr = formatInTimeZone(now, timezone, "yyyy-MM-dd"); return `${prefix}:daily:${dateStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "weekly") { - // leaderboard:v3:{scope}:weekly:2025-W03:USD (ISO week) + // leaderboard:v4:{scope}:weekly:2025-W03:USD (ISO week) const weekStr = formatInTimeZone(now, timezone, "yyyy-'W'ww"); return `${prefix}:weekly:${weekStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else if (period === "monthly") { - // leaderboard:v3:{scope}:monthly:2025-01:USD + // leaderboard:v4:{scope}:monthly:2025-01:USD const monthStr = formatInTimeZone(now, timezone, "yyyy-MM"); return `${prefix}:monthly:${monthStr}:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } else { - // allTime: leaderboard:v3:{scope}:allTime:USD (no date component) + // allTime: leaderboard:v4:{scope}:allTime:USD (no date component) return `${prefix}:allTime:tz:${timezone}:${currencyDisplay}${providerTypeSuffix}${includeModelStatsSuffix}${userFilterSuffix}`; } } diff --git a/src/lib/usage-logs/export/columns.ts b/src/lib/usage-logs/export/columns.ts index f632ed2d6..f1abd6404 100644 --- a/src/lib/usage-logs/export/columns.ts +++ b/src/lib/usage-logs/export/columns.ts @@ -31,6 +31,16 @@ function retryCountOf(log: UsageLogRow): number { return log.providerChain ? getRetryCount(log.providerChain) : 0; } +function prefixIdOf(log: UsageLogRow): string { + return log.sessionIdentityKind === "prefix_affinity" ? (log.sessionId ?? "") : ""; +} + +function physicalSessionIdOf(log: UsageLogRow): string { + return log.sessionIdentityKind === "prefix_affinity" + ? (log.sourceSessionId ?? "") + : (log.sessionId ?? ""); +} + export const DETAIL_COLUMNS: DetailColumn[] = [ { header: "Time", kind: "datetime", numFmt: DATETIME_NUM_FMT, get: (log) => log.createdAt }, { header: "User", kind: "text", get: (log) => log.userName }, @@ -90,7 +100,8 @@ export const DETAIL_COLUMNS: DetailColumn[] = [ get: (log) => log.costUsd, }, { header: "Duration (ms)", kind: "number", numFmt: INT_NUM_FMT, get: (log) => log.durationMs }, - { header: "Session ID", kind: "text", get: (log) => log.sessionId ?? "" }, + { header: "Prefix ID", kind: "text", get: prefixIdOf }, + { header: "Session ID", kind: "text", get: physicalSessionIdOf }, { header: "Retry Count", kind: "number", diff --git a/src/repository/leaderboard.ts b/src/repository/leaderboard.ts index a4b78bef5..7872ce887 100644 --- a/src/repository/leaderboard.ts +++ b/src/repository/leaderboard.ts @@ -13,6 +13,7 @@ import { } from "./_shared/ledger-conditions"; import { getProviderCacheCoefficients, + getProviderModelCacheCoefficients, resolveLeaderboardWindow, } from "./provider-cache-effectiveness"; import { getSystemSettings } from "./system-config"; @@ -95,11 +96,13 @@ export interface ModelProviderStat { avgTokensPerSecond: number; // tok/s avgCostPerRequest: number | null; avgCostPerMillionTokens: number | null; + /** 重定向模型口径的缓存系数;original 口径无法可靠映射时为 null */ + cacheCoefficientBp: number | null; rowIdentityBasis?: BillingModelSource; - successRateBasis?: "original" | "unavailable"; + successRateBasis?: BillingModelSource; costTokensBasis?: BillingModelSource; basisDisclosureRequired?: boolean; - successRateUnavailableReason?: "redirected_billing_model"; + successRateUnavailableReason?: "no_countable_outcomes"; } /** @@ -111,6 +114,8 @@ export interface ModelCacheHitStat { cacheReadTokens: number; totalInputTokens: number; cacheHitRate: number; // 0-1 + /** 重定向模型口径的缓存系数;original 口径无法可靠映射时为 null */ + cacheCoefficientBp: number | null; } /** @@ -170,10 +175,10 @@ export interface ModelLeaderboardEntry { totalTokens: number; successRate: number | null; // 0-1 之间的小数,UI 层负责格式化为百分比 rowIdentityBasis?: BillingModelSource; - successRateBasis?: "original" | "unavailable"; + successRateBasis?: BillingModelSource; costTokensBasis?: BillingModelSource; basisDisclosureRequired?: boolean; - successRateUnavailableReason?: "redirected_billing_model"; + successRateUnavailableReason?: "no_countable_outcomes"; } /** @@ -737,6 +742,10 @@ async function findProviderLeaderboardWithTimezone( // Model breakdown per provider const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; + const modelCacheCoefficientsPromise = + billingModelSource === "redirected" + ? getProviderModelCacheCoefficients(resolveLeaderboardWindow(period, timezone, dateRange)) + : Promise.resolve(new Map()); const rawModelField = billingModelSource === "original" ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` @@ -764,6 +773,7 @@ async function findProviderLeaderboardWithTimezone( ) .groupBy(usageLedger.finalProviderId, modelField) .orderBy(desc(sql`COALESCE(sum(${usageLedger.costUsd}), 0)`), desc(sql`count(*)`)); + const modelCacheCoefficients = (await modelCacheCoefficientsPromise) ?? new Map(); const modelStatsByProvider = new Map(); for (const row of modelRows) { @@ -774,21 +784,27 @@ async function findProviderLeaderboardWithTimezone( const avgCosts = computeAvgCosts(totalCost, totalRequests, totalTokens); const stats = modelStatsByProvider.get(row.providerId) ?? []; const basisDisclosureRequired = billingModelSource !== "original"; + const successRate = clampRatio01Nullable(row.successRate); + const modelCacheKey = row.model.trim(); stats.push({ model: row.model, totalRequests, totalCost, totalTokens, - successRate: basisDisclosureRequired ? null : clampRatio01Nullable(row.successRate), + successRate, avgTtftMs: row.avgTtftMs ?? 0, avgTokensPerSecond: row.avgTokensPerSecond ?? 0, rowIdentityBasis: billingModelSource, - successRateBasis: basisDisclosureRequired ? "unavailable" : "original", + successRateBasis: billingModelSource, costTokensBasis: billingModelSource, basisDisclosureRequired, - successRateUnavailableReason: basisDisclosureRequired - ? "redirected_billing_model" - : undefined, + ...(successRate === null + ? ({ successRateUnavailableReason: "no_countable_outcomes" } as const) + : {}), + cacheCoefficientBp: + billingModelSource === "redirected" + ? (modelCacheCoefficients.get(row.providerId)?.get(modelCacheKey)?.coefficientBp ?? null) + : null, ...avgCosts, }); modelStatsByProvider.set(row.providerId, stats); @@ -870,6 +886,10 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( // Model-level cache hit breakdown per provider const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; + const modelCacheCoefficientsPromise = + billingModelSource === "redirected" + ? getProviderModelCacheCoefficients(resolveLeaderboardWindow(period, timezone, dateRange)) + : Promise.resolve(new Map()); const rawModelField = billingModelSource === "original" ? sql`COALESCE(${usageLedger.originalModel}, ${usageLedger.model})` @@ -902,18 +922,24 @@ async function findProviderCacheHitRateLeaderboardWithTimezone( ) .groupBy(usageLedger.finalProviderId, modelField) .orderBy(desc(modelCacheHitRate), desc(sql`count(*)`)); + const modelCacheCoefficients = (await modelCacheCoefficientsPromise) ?? new Map(); // Group model stats by providerId const modelStatsByProvider = new Map(); for (const row of modelRows) { if (!row.model) continue; const stats = modelStatsByProvider.get(row.providerId) ?? []; + const modelCacheKey = row.model.trim(); stats.push({ model: row.model, totalRequests: row.totalRequests, cacheReadTokens: row.cacheReadTokens, totalInputTokens: row.totalInputTokens, cacheHitRate: clampRatio01(row.cacheHitRate), + cacheCoefficientBp: + billingModelSource === "redirected" + ? (modelCacheCoefficients.get(row.providerId)?.get(modelCacheKey)?.coefficientBp ?? null) + : null, }); modelStatsByProvider.set(row.providerId, stats); } @@ -1190,20 +1216,23 @@ async function findModelLeaderboardWithTimezone( return rankings .filter((entry) => entry.model !== null && entry.model !== "") - .map((entry) => ({ - model: entry.model as string, // 已过滤 null/空字符串,可安全断言 - totalRequests: entry.totalRequests, - totalCost: parseFloat(entry.totalCost), - totalTokens: entry.totalTokens, - successRate: - billingModelSource === "original" ? clampRatio01Nullable(entry.successRate) : null, - rowIdentityBasis: billingModelSource, - successRateBasis: billingModelSource === "original" ? "original" : "unavailable", - costTokensBasis: billingModelSource, - basisDisclosureRequired: billingModelSource !== "original", - successRateUnavailableReason: - billingModelSource !== "original" ? "redirected_billing_model" : undefined, - })); + .map((entry) => { + const successRate = clampRatio01Nullable(entry.successRate); + return { + model: entry.model as string, // 已过滤 null/空字符串,可安全断言 + totalRequests: entry.totalRequests, + totalCost: parseFloat(entry.totalCost), + totalTokens: entry.totalTokens, + successRate, + rowIdentityBasis: billingModelSource, + successRateBasis: billingModelSource, + costTokensBasis: billingModelSource, + basisDisclosureRequired: billingModelSource !== "original", + ...(successRate === null + ? ({ successRateUnavailableReason: "no_countable_outcomes" } as const) + : {}), + }; + }); } /** diff --git a/src/repository/provider-cache-effectiveness.ts b/src/repository/provider-cache-effectiveness.ts index 9c6cfc001..f8db4440b 100644 --- a/src/repository/provider-cache-effectiveness.ts +++ b/src/repository/provider-cache-effectiveness.ts @@ -44,6 +44,15 @@ export interface ProviderCacheCoefficient { sampleCount: number; } +export interface ProviderModelCacheCoefficient extends ProviderCacheCoefficient { + model: string; +} + +export type ProviderModelCacheCoefficientMap = Map< + number, + Map +>; + // tsconfig target ES2017 禁 BigInt 字面量,统一用 BigInt() 构造 const BIG_ZERO = BigInt(0); const BP_SCALE = BigInt(10000); @@ -57,6 +66,7 @@ function computeCoefficientBp( ): number { let rawBp = theoretical > BIG_ZERO ? (observed * BP_SCALE) / theoretical : BIG_ZERO; if (rawBp > BP_SCALE) rawBp = BP_SCALE; + if (rawBp < BIG_ZERO) rawBp = BIG_ZERO; const sampleFactorBp = eligible >= BigInt(100) ? BigInt(10000) @@ -164,3 +174,56 @@ export async function getProviderCacheCoefficients({ } return coefficients; } + +/** + * 聚合排行榜周期内的 provider + model 缓存系数。 + * + * message_request.model 保存重定向后的实际请求模型,因此本结果只用于 + * billingModelSource=redirected 的模型子行;original 口径无法可靠映射时保持无数据。 + */ +export async function getProviderModelCacheCoefficients({ + start, + end, +}: { + start: Date; + end: Date; +}): Promise { + const normalizedModel = sql`TRIM(${providerCacheEffectiveness.model})`; + const rows = await db + .select({ + providerId: providerCacheEffectiveness.providerId, + model: normalizedModel, + sampleCount: sql`COALESCE(sum(${providerCacheEffectiveness.sampleCount}), 0)::bigint`, + eligibleCount: sql`COALESCE(sum(${providerCacheEffectiveness.eligibleCount}), 0)::bigint`, + theoreticalCacheTokens: sql`COALESCE(sum(${providerCacheEffectiveness.theoreticalCacheTokens}), 0)::bigint`, + observedCacheReadTokens: sql`COALESCE(sum(${providerCacheEffectiveness.observedCacheReadTokens}), 0)::bigint`, + }) + .from(providerCacheEffectiveness) + .where( + and( + gt(providerCacheEffectiveness.windowEnd, start), + lte(providerCacheEffectiveness.windowEnd, end), + sql`${normalizedModel} <> ''` + ) + ) + .groupBy(providerCacheEffectiveness.providerId, normalizedModel); + + const coefficients: ProviderModelCacheCoefficientMap = new Map(); + for (const row of rows) { + const sample = BigInt(row.sampleCount); + const providerModels = coefficients.get(row.providerId) ?? new Map(); + providerModels.set(row.model, { + providerId: row.providerId, + model: row.model, + coefficientBp: computeCoefficientBp( + sample, + BigInt(row.eligibleCount), + BigInt(row.theoreticalCacheTokens), + BigInt(row.observedCacheReadTokens) + ), + sampleCount: Number(sample), + }); + coefficients.set(row.providerId, providerModels); + } + return coefficients; +} diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 93a94419a..ce4cfbb2b 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -5,6 +5,10 @@ import { and, desc, eq, gte, inArray, isNull, lt, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, usageLedger, users } from "@/drizzle/schema"; import { TTLMap } from "@/lib/cache/ttl-map"; +import { + deriveRequestCacheMetrics, + type RequestCacheMetricAvailability, +} from "@/lib/cache-effectiveness/request-metrics"; import { isLedgerOnlyMode } from "@/lib/ledger-fallback"; import { extractAnthropicEffortFromSpecialSettings } from "@/lib/utils/anthropic-effort"; import { isNonBillingEndpoint } from "@/lib/utils/performance-formatter"; @@ -215,6 +219,7 @@ export interface UsageLogRow { sessionId: string | null; // Public Session identity sourceSessionId: string | null; // Physical Session source for request-scoped readback sourceSessionIds?: string[]; // All physical client session IDs grouped under the public identity + sessionIdentityKind?: "session_id" | "prefix_affinity" | null; requestSequence: number | null; // Request Sequence(Session 内请求序号) userName: string; keyName: string; @@ -231,6 +236,14 @@ export interface UsageLogRow { cacheCreation5mInputTokens: number | null; cacheCreation1hInputTokens: number | null; cacheTtlApplied: string | null; + theoreticalCacheTokens: number | null; + cacheScoreEligible: boolean | null; + cacheScoreExcludedReason: string | null; + cacheInputTotal: number; + actualCacheRate: number | null; + theoreticalCacheRate: number | null; + requestCacheCoefficientBp: number | null; + requestCacheMetricAvailability: RequestCacheMetricAvailability; totalTokens: number; costUsd: string | null; costMultiplier: string | null; // 供应商倍率 @@ -366,6 +379,7 @@ export async function findUsageLogsBatch( createdAtRaw: sql`to_char(${messageRequest.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, sessionId: messageSessionIdentity, sourceSessionId: messageRequest.sessionId, + sessionIdentityKind: messageRequest.sessionIdentityKind, requestSequence: messageRequest.requestSequence, userName: users.name, keyName: keysTable.name, @@ -382,6 +396,9 @@ export async function findUsageLogsBatch( cacheCreation5mInputTokens: messageRequest.cacheCreation5mInputTokens, cacheCreation1hInputTokens: messageRequest.cacheCreation1hInputTokens, cacheTtlApplied: messageRequest.cacheTtlApplied, + theoreticalCacheTokens: messageRequest.theoreticalCacheTokens, + cacheScoreEligible: messageRequest.cacheScoreEligible, + cacheScoreExcludedReason: messageRequest.cacheScoreExcludedReason, costUsd: messageRequest.costUsd, costMultiplier: messageRequest.costMultiplier, groupCostMultiplier: messageRequest.groupCostMultiplier, @@ -440,6 +457,15 @@ export async function findUsageLogsBatch( context1mApplied: row.context1mApplied, }); const anthropicEffort = extractAnthropicEffortFromSpecialSettings(unifiedSpecialSettings); + const cacheMetrics = deriveRequestCacheMetrics({ + inputTokens: row.inputTokens, + cacheCreationInputTokens: row.cacheCreationInputTokens, + cacheReadInputTokens: row.cacheReadInputTokens, + theoreticalCacheTokens: row.theoreticalCacheTokens, + cacheScoreEligible: row.cacheScoreEligible, + cacheScoreExcludedReason: row.cacheScoreExcludedReason, + sessionIdentityKind: row.sessionIdentityKind, + }); return { ...row, @@ -448,6 +474,7 @@ export async function findUsageLogsBatch( cacheCreation5mInputTokens: row.cacheCreation5mInputTokens, cacheCreation1hInputTokens: row.cacheCreation1hInputTokens, cacheTtlApplied: row.cacheTtlApplied, + ...cacheMetrics, costUsd: row.costUsd?.toString() ?? null, groupCostMultiplier: row.groupCostMultiplier?.toString() ?? null, costBreakdown: (row.costBreakdown as StoredCostBreakdown) ?? null, @@ -564,6 +591,7 @@ export async function findUsageLogsBatch( createdAtRaw: sql`to_char(${usageLedger.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, sessionId: ledgerSessionIdentity, sourceSessionId: usageLedger.sessionId, + sessionIdentityKind: usageLedger.sessionIdentityKind, userId: usageLedger.userId, userName: users.name, key: usageLedger.key, @@ -616,12 +644,22 @@ export async function findUsageLogsBatch( (row.outputTokens ?? 0) + (row.cacheCreationInputTokens ?? 0) + (row.cacheReadInputTokens ?? 0); + const cacheMetrics = deriveRequestCacheMetrics({ + inputTokens: row.inputTokens, + cacheCreationInputTokens: row.cacheCreationInputTokens, + cacheReadInputTokens: row.cacheReadInputTokens, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + sessionIdentityKind: row.sessionIdentityKind, + }); return { id: row.id, createdAt: row.createdAt, sessionId: row.sessionId, sourceSessionId: row.sourceSessionId, + sessionIdentityKind: row.sessionIdentityKind, requestSequence: null, userName: row.userName ?? `User #${row.userId}`, keyName: row.keyName ?? row.key, @@ -638,6 +676,10 @@ export async function findUsageLogsBatch( cacheCreation5mInputTokens: row.cacheCreation5mInputTokens, cacheCreation1hInputTokens: row.cacheCreation1hInputTokens, cacheTtlApplied: row.cacheTtlApplied, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + ...cacheMetrics, totalTokens: totalRowTokens, costUsd: row.costUsd?.toString() ?? null, costMultiplier: row.costMultiplier?.toString() ?? null, @@ -704,6 +746,7 @@ interface UsageLogSlimBatchFilters extends UsageLogSlimFilters { interface UsageLogSlimRow { id: number; createdAt: Date | null; + sessionIdentityKind: "session_id" | "prefix_affinity" | null; model: string | null; originalModel: string | null; actualResponseModel: string | null; @@ -718,6 +761,14 @@ interface UsageLogSlimRow { cacheCreation5mInputTokens: number | null; cacheCreation1hInputTokens: number | null; cacheTtlApplied: string | null; + theoreticalCacheTokens: number | null; + cacheScoreEligible: boolean | null; + cacheScoreExcludedReason: string | null; + cacheInputTotal: number; + actualCacheRate: number | null; + theoreticalCacheRate: number | null; + requestCacheCoefficientBp: number | null; + requestCacheMetricAvailability: RequestCacheMetricAvailability; isReplay: boolean; replaySourceRequestId: number | null; anthropicEffort?: string | null; @@ -840,6 +891,7 @@ function buildNextCursorOrThrow( function mapUsageLogSlimRow(row: { id: number; createdAt: Date | null; + sessionIdentityKind: "session_id" | "prefix_affinity" | null; model: string | null; originalModel: string | null; actualResponseModel: string | null; @@ -854,6 +906,9 @@ function mapUsageLogSlimRow(row: { cacheCreation5mInputTokens: number | null; cacheCreation1hInputTokens: number | null; cacheTtlApplied: string | null; + theoreticalCacheTokens: number | null; + cacheScoreEligible: boolean | null; + cacheScoreExcludedReason: string | null; isReplay: boolean; replaySourceRequestId: number | null; specialSettings?: SpecialSetting[] | null; @@ -868,9 +923,19 @@ function mapUsageLogSlimRow(row: { context1mApplied: null, }); const anthropicEffort = extractAnthropicEffortFromSpecialSettings(unifiedSpecialSettings); + const cacheMetrics = deriveRequestCacheMetrics({ + inputTokens: rest.inputTokens, + cacheCreationInputTokens: rest.cacheCreationInputTokens, + cacheReadInputTokens: rest.cacheReadInputTokens, + theoreticalCacheTokens: rest.theoreticalCacheTokens, + cacheScoreEligible: rest.cacheScoreEligible, + cacheScoreExcludedReason: rest.cacheScoreExcludedReason, + sessionIdentityKind: rest.sessionIdentityKind, + }); return { ...rest, + ...cacheMetrics, costUsd: rest.costUsd?.toString() ?? null, anthropicEffort, }; @@ -1016,6 +1081,7 @@ async function selectKeyScopedMessageSlimRows( id: messageRequest.id, createdAt: messageRequest.createdAt, createdAtRaw: sql`to_char(${messageRequest.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, + sessionIdentityKind: messageRequest.sessionIdentityKind, model: messageRequest.model, originalModel: messageRequest.originalModel, actualResponseModel: messageRequest.actualResponseModel, @@ -1030,6 +1096,9 @@ async function selectKeyScopedMessageSlimRows( cacheCreation5mInputTokens: messageRequest.cacheCreation5mInputTokens, cacheCreation1hInputTokens: messageRequest.cacheCreation1hInputTokens, cacheTtlApplied: messageRequest.cacheTtlApplied, + theoreticalCacheTokens: messageRequest.theoreticalCacheTokens, + cacheScoreEligible: messageRequest.cacheScoreEligible, + cacheScoreExcludedReason: messageRequest.cacheScoreExcludedReason, isReplay: messageRequest.isReplay, replaySourceRequestId: messageRequest.replaySourceRequestId, specialSettings: messageRequest.specialSettings, @@ -1062,6 +1131,7 @@ async function selectKeyScopedLedgerSlimRows( id: usageLedger.requestId, createdAt: usageLedger.createdAt, createdAtRaw: sql`to_char(${usageLedger.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, + sessionIdentityKind: usageLedger.sessionIdentityKind, model: usageLedger.model, originalModel: usageLedger.originalModel, actualResponseModel: usageLedger.actualResponseModel, @@ -1086,26 +1156,15 @@ async function selectKeyScopedLedgerSlimRows( .offset(offset); return rows.map((row) => ({ - id: row.id, - createdAt: row.createdAt, + ...mapUsageLogSlimRow({ + ...row, + costUsd: row.costUsd?.toString() ?? null, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + specialSettings: null, + }), createdAtRaw: row.createdAtRaw, - model: row.model, - originalModel: row.originalModel, - actualResponseModel: row.actualResponseModel, - endpoint: row.endpoint, - statusCode: row.statusCode, - inputTokens: row.inputTokens, - outputTokens: row.outputTokens, - costUsd: row.costUsd?.toString() ?? null, - durationMs: row.durationMs, - cacheCreationInputTokens: row.cacheCreationInputTokens, - cacheReadInputTokens: row.cacheReadInputTokens, - cacheCreation5mInputTokens: row.cacheCreation5mInputTokens, - cacheCreation1hInputTokens: row.cacheCreation1hInputTokens, - cacheTtlApplied: row.cacheTtlApplied, - isReplay: row.isReplay, - replaySourceRequestId: row.replaySourceRequestId, - anthropicEffort: null, })); } @@ -1195,6 +1254,7 @@ function mapUsageLogRowFromMessageResult(row: { sessionId: string | null; sourceSessionId: string | null; sourceSessionIds?: string[]; + sessionIdentityKind: "session_id" | "prefix_affinity" | null; requestSequence: number | null; userName: string; keyName: string; @@ -1211,6 +1271,9 @@ function mapUsageLogRowFromMessageResult(row: { cacheCreation5mInputTokens: number | null; cacheCreation1hInputTokens: number | null; cacheTtlApplied: string | null; + theoreticalCacheTokens: number | null; + cacheScoreEligible: boolean | null; + cacheScoreExcludedReason: string | null; costUsd: string | null | { toString(): string }; costMultiplier: string | null | { toString(): string }; groupCostMultiplier: string | null | { toString(): string }; @@ -1248,13 +1311,24 @@ function mapUsageLogRowFromMessageResult(row: { context1mApplied: row.context1mApplied, }); const anthropicEffort = extractAnthropicEffortFromSpecialSettings(unifiedSpecialSettings); + const cacheMetrics = deriveRequestCacheMetrics({ + inputTokens: row.inputTokens, + cacheCreationInputTokens: row.cacheCreationInputTokens, + cacheReadInputTokens: row.cacheReadInputTokens, + theoreticalCacheTokens: row.theoreticalCacheTokens, + cacheScoreEligible: row.cacheScoreEligible, + cacheScoreExcludedReason: row.cacheScoreExcludedReason, + sessionIdentityKind: row.sessionIdentityKind, + }); return { ...row, sourceSessionIds: row.sourceSessionIds ? [...new Set(row.sourceSessionIds)] : undefined, + sessionIdentityKind: row.sessionIdentityKind, requestSequence: row.requestSequence ?? null, totalTokens: totalRowTokens, costUsd: row.costUsd?.toString() ?? null, + ...cacheMetrics, costMultiplier: row.costMultiplier?.toString() ?? null, groupCostMultiplier: row.groupCostMultiplier?.toString() ?? null, costBreakdown: row.costBreakdown ?? null, @@ -1272,6 +1346,7 @@ function mapUsageLogRowFromLedgerResult(row: { sessionId: string | null; sourceSessionId: string | null; sourceSessionIds?: string[]; + sessionIdentityKind: "session_id" | "prefix_affinity" | null; userId: number; userName: string | null; key: string; @@ -1306,6 +1381,15 @@ function mapUsageLogRowFromLedgerResult(row: { (row.outputTokens ?? 0) + (row.cacheCreationInputTokens ?? 0) + (row.cacheReadInputTokens ?? 0); + const cacheMetrics = deriveRequestCacheMetrics({ + inputTokens: row.inputTokens, + cacheCreationInputTokens: row.cacheCreationInputTokens, + cacheReadInputTokens: row.cacheReadInputTokens, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + sessionIdentityKind: row.sessionIdentityKind, + }); return { id: row.id, @@ -1313,6 +1397,7 @@ function mapUsageLogRowFromLedgerResult(row: { sessionId: row.sessionId, sourceSessionId: row.sourceSessionId, sourceSessionIds: row.sourceSessionIds ? [...new Set(row.sourceSessionIds)] : undefined, + sessionIdentityKind: row.sessionIdentityKind, requestSequence: null, userName: row.userName ?? `User #${row.userId}`, keyName: row.keyName ?? row.key, @@ -1329,6 +1414,10 @@ function mapUsageLogRowFromLedgerResult(row: { cacheCreation5mInputTokens: row.cacheCreation5mInputTokens, cacheCreation1hInputTokens: row.cacheCreation1hInputTokens, cacheTtlApplied: row.cacheTtlApplied, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + ...cacheMetrics, totalTokens: totalRowTokens, costUsd: row.costUsd?.toString() ?? null, costMultiplier: row.costMultiplier?.toString() ?? null, @@ -1374,6 +1463,7 @@ export async function findReadonlyUsageLogsBatchForKey( createdAtRaw: sql`to_char(${messageRequest.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, sessionId: messageSessionIdentity, sourceSessionId: messageRequest.sessionId, + sessionIdentityKind: messageRequest.sessionIdentityKind, requestSequence: messageRequest.requestSequence, userName: users.name, keyName: keysTable.name, @@ -1390,6 +1480,9 @@ export async function findReadonlyUsageLogsBatchForKey( cacheCreation5mInputTokens: messageRequest.cacheCreation5mInputTokens, cacheCreation1hInputTokens: messageRequest.cacheCreation1hInputTokens, cacheTtlApplied: messageRequest.cacheTtlApplied, + theoreticalCacheTokens: messageRequest.theoreticalCacheTokens, + cacheScoreEligible: messageRequest.cacheScoreEligible, + cacheScoreExcludedReason: messageRequest.cacheScoreExcludedReason, costUsd: messageRequest.costUsd, costMultiplier: messageRequest.costMultiplier, groupCostMultiplier: messageRequest.groupCostMultiplier, @@ -1427,6 +1520,7 @@ export async function findReadonlyUsageLogsBatchForKey( createdAtRaw: sql`to_char(${usageLedger.createdAt} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`, sessionId: ledgerSessionIdentity, sourceSessionId: usageLedger.sessionId, + sessionIdentityKind: usageLedger.sessionIdentityKind, userId: usageLedger.userId, userName: users.name, key: usageLedger.key, @@ -1645,6 +1739,7 @@ export async function findUsageLogsWithDetails( createdAt: messageRequest.createdAt, sessionId: messageSessionIdentity, // Public Session identity sourceSessionId: messageRequest.sessionId, // Physical Session source + sessionIdentityKind: messageRequest.sessionIdentityKind, requestSequence: messageRequest.requestSequence, // Request Sequence userName: users.name, keyName: keysTable.name, @@ -1661,6 +1756,9 @@ export async function findUsageLogsWithDetails( cacheCreation5mInputTokens: messageRequest.cacheCreation5mInputTokens, cacheCreation1hInputTokens: messageRequest.cacheCreation1hInputTokens, cacheTtlApplied: messageRequest.cacheTtlApplied, + theoreticalCacheTokens: messageRequest.theoreticalCacheTokens, + cacheScoreEligible: messageRequest.cacheScoreEligible, + cacheScoreExcludedReason: messageRequest.cacheScoreExcludedReason, costUsd: messageRequest.costUsd, costMultiplier: messageRequest.costMultiplier, // 供应商倍率 groupCostMultiplier: messageRequest.groupCostMultiplier, // 分组倍率 @@ -1724,6 +1822,15 @@ export async function findUsageLogsWithDetails( context1mApplied: row.context1mApplied, }); const anthropicEffort = extractAnthropicEffortFromSpecialSettings(unifiedSpecialSettings); + const cacheMetrics = deriveRequestCacheMetrics({ + inputTokens: row.inputTokens, + cacheCreationInputTokens: row.cacheCreationInputTokens, + cacheReadInputTokens: row.cacheReadInputTokens, + theoreticalCacheTokens: row.theoreticalCacheTokens, + cacheScoreEligible: row.cacheScoreEligible, + cacheScoreExcludedReason: row.cacheScoreExcludedReason, + sessionIdentityKind: row.sessionIdentityKind, + }); return { ...row, @@ -1732,6 +1839,7 @@ export async function findUsageLogsWithDetails( cacheCreation5mInputTokens: row.cacheCreation5mInputTokens, cacheCreation1hInputTokens: row.cacheCreation1hInputTokens, cacheTtlApplied: row.cacheTtlApplied, + ...cacheMetrics, costUsd: row.costUsd?.toString() ?? null, groupCostMultiplier: row.groupCostMultiplier?.toString() ?? null, costBreakdown: (row.costBreakdown as StoredCostBreakdown) ?? null, diff --git a/tests/unit/actions/my-usage-actions-unit.test.ts b/tests/unit/actions/my-usage-actions-unit.test.ts index 7528d4075..7401346a7 100644 --- a/tests/unit/actions/my-usage-actions-unit.test.ts +++ b/tests/unit/actions/my-usage-actions-unit.test.ts @@ -171,6 +171,14 @@ function buildSlimRow(overrides: Record = {}) { cacheCreation5mInputTokens: 5, cacheCreation1hInputTokens: 5, cacheTtlApplied: "5m", + theoreticalCacheTokens: 40, + cacheScoreEligible: true, + cacheScoreExcludedReason: null, + cacheInputTotal: 130, + actualCacheRate: 20 / 130, + theoreticalCacheRate: 40 / 130, + requestCacheCoefficientBp: 5000, + requestCacheMetricAvailability: "available", anthropicEffort: "high", ...overrides, }; @@ -343,6 +351,14 @@ describe("getMyUsageLogs", () => { cacheCreation5mInputTokens: null, cacheCreation1hInputTokens: null, cacheTtlApplied: null, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + cacheInputTotal: 0, + actualCacheRate: null, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "not_recorded", anthropicEffort: null, }), ], @@ -376,6 +392,13 @@ describe("getMyUsageLogs", () => { cacheCreation5mInputTokens: 5, cacheCreation1hInputTokens: 5, cacheTtlApplied: "5m", + theoreticalCacheTokens: 40, + cacheScoreEligible: true, + cacheInputTotal: 130, + actualCacheRate: 20 / 130, + theoreticalCacheRate: 40 / 130, + requestCacheCoefficientBp: 5000, + requestCacheMetricAvailability: "available", }); expect(result.data.logs[1]).toMatchObject({ id: 2, @@ -502,7 +525,16 @@ describe("getMyUsageLogsBatch", () => { if (!result.ok) return; expect(result.data.hasMore).toBe(true); expect(result.data.nextCursor).toEqual({ createdAt: "2024-06-01T10:00:00.000Z", id: 1 }); - expect(result.data.logs[0]?.id).toBe(1); + expect(result.data.logs[0]).toMatchObject({ + id: 1, + theoreticalCacheTokens: 40, + cacheScoreEligible: true, + cacheInputTotal: 130, + actualCacheRate: 20 / 130, + theoreticalCacheRate: 40 / 130, + requestCacheCoefficientBp: 5000, + requestCacheMetricAvailability: "available", + }); }); test("limit<=0:应回退默认 20", async () => { diff --git a/tests/unit/api/leaderboard-route.test.ts b/tests/unit/api/leaderboard-route.test.ts index 8e8cc205f..e63842bfd 100644 --- a/tests/unit/api/leaderboard-route.test.ts +++ b/tests/unit/api/leaderboard-route.test.ts @@ -177,12 +177,11 @@ describe("GET /api/leaderboard", () => { totalRequests: 12, totalCost: 3.5, totalTokens: 12345, - successRate: null, + successRate: 0.9, rowIdentityBasis: "redirected", - successRateBasis: "unavailable", + successRateBasis: "redirected", costTokensBasis: "redirected", basisDisclosureRequired: true, - successRateUnavailableReason: "redirected_billing_model", }, ]); @@ -194,12 +193,11 @@ describe("GET /api/leaderboard", () => { expect(response.status).toBe(200); expect(body[0]).toMatchObject({ model: "glm-4.6", - successRate: null, + successRate: 0.9, rowIdentityBasis: "redirected", - successRateBasis: "unavailable", + successRateBasis: "redirected", costTokensBasis: "redirected", basisDisclosureRequired: true, - successRateUnavailableReason: "redirected_billing_model", }); }); diff --git a/tests/unit/dashboard/leaderboard-success-rate-display.test.ts b/tests/unit/dashboard/leaderboard-success-rate-display.test.ts index c117be05b..9b209981a 100644 --- a/tests/unit/dashboard/leaderboard-success-rate-display.test.ts +++ b/tests/unit/dashboard/leaderboard-success-rate-display.test.ts @@ -5,7 +5,7 @@ describe("getSuccessRateCellDisplay", () => { const t = (key: string) => ({ "columns.successRateUnavailable": "N/A", - "columns.successRateBasisDisclosure": "basis disclosure", + "columns.successRateBasisDisclosure": "no countable outcomes", })[key] ?? key; it("formats numeric success rate as percentage", () => { @@ -15,18 +15,49 @@ describe("getSuccessRateCellDisplay", () => { }); }); - it("shows unavailable label with disclosure when basis diverges", () => { + it("formats redirected numeric success rate without an unavailable tooltip", () => { + expect( + getSuccessRateCellDisplay( + { + successRate: 0.875, + basisDisclosureRequired: true, + }, + t as never + ) + ).toEqual({ + label: "87.5%", + title: undefined, + }); + }); + + it("shows unavailable reason only when there are no countable outcomes", () => { expect( getSuccessRateCellDisplay( { successRate: null, basisDisclosureRequired: true, + successRateUnavailableReason: "no_countable_outcomes", }, t as never ) ).toEqual({ label: "N/A", - title: "basis disclosure", + title: "no countable outcomes", + }); + }); + + it("does not explain null success rate as redirected billing", () => { + expect( + getSuccessRateCellDisplay( + { + successRate: null, + basisDisclosureRequired: true, + }, + t as never + ) + ).toEqual({ + label: "N/A", + title: undefined, }); }); }); diff --git a/tests/unit/instrumentation-crash-handler.test.ts b/tests/unit/instrumentation-crash-handler.test.ts index b195e69b4..3bf84dca8 100644 --- a/tests/unit/instrumentation-crash-handler.test.ts +++ b/tests/unit/instrumentation-crash-handler.test.ts @@ -23,7 +23,7 @@ vi.mock("@/lib/logger", () => ({ })); import { logger } from "@/lib/logger"; -import { registerCrashDiagnostics } from "@/instrumentation"; +import { describeSchedulerError, registerCrashDiagnostics } from "@/instrumentation"; type CrashHandler = (arg: unknown) => void; @@ -194,3 +194,28 @@ describe("registerCrashDiagnostics", () => { }); }); }); + +describe("describeSchedulerError", () => { + it("truncates Error causes and preserves their metadata", () => { + const cause = Object.assign(new Error("x".repeat(700)), { code: "ERR_DRIVER" }); + const error = new Error("outer", { cause }); + + expect(describeSchedulerError(error)).toEqual({ + error: "outer", + errorName: "Error", + errorCause: "x".repeat(500), + errorCauseName: "Error", + errorCauseCode: "ERR_DRIVER", + }); + }); + + it("keeps cause fields undefined when no cause exists", () => { + expect(describeSchedulerError(new Error("outer"))).toEqual({ + error: "outer", + errorName: "Error", + errorCause: undefined, + errorCauseName: undefined, + errorCauseCode: undefined, + }); + }); +}); diff --git a/tests/unit/lib/cache-effectiveness/request-metrics.test.ts b/tests/unit/lib/cache-effectiveness/request-metrics.test.ts new file mode 100644 index 000000000..9e3e2211c --- /dev/null +++ b/tests/unit/lib/cache-effectiveness/request-metrics.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from "vitest"; +import { deriveRequestCacheMetrics } from "@/lib/cache-effectiveness/request-metrics"; + +const eligible = { + inputTokens: 100, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 30, + theoreticalCacheTokens: 60, + cacheScoreEligible: true, + cacheScoreExcludedReason: null, + sessionIdentityKind: "prefix_affinity" as const, +}; + +describe("deriveRequestCacheMetrics", () => { + test("derives actual rate, theoretical rate, and raw coefficient", () => { + expect(deriveRequestCacheMetrics(eligible)).toEqual({ + cacheInputTotal: 150, + actualCacheRate: 0.2, + theoreticalCacheRate: 0.4, + requestCacheCoefficientBp: 5000, + requestCacheMetricAvailability: "available", + }); + }); + + test("keeps zero actual cache rate when input exists", () => { + expect( + deriveRequestCacheMetrics({ + ...eligible, + cacheReadInputTokens: 0, + }).actualCacheRate + ).toBe(0); + }); + + test("returns no_input without fabricating zero rates", () => { + expect( + deriveRequestCacheMetrics({ + ...eligible, + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }) + ).toMatchObject({ + actualCacheRate: null, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "no_input", + }); + }); + + test("marks missing prefix provenance as unavailable", () => { + expect( + deriveRequestCacheMetrics({ + ...eligible, + theoreticalCacheTokens: null, + cacheScoreEligible: false, + cacheScoreExcludedReason: "no_affinity_key", + sessionIdentityKind: "session_id", + }) + ).toMatchObject({ + actualCacheRate: 0.2, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "no_affinity_key", + }); + }); + + test("uses no_affinity_key when a recorded request has no theoretical token estimate", () => { + expect( + deriveRequestCacheMetrics({ + ...eligible, + theoreticalCacheTokens: null, + cacheScoreEligible: false, + cacheScoreExcludedReason: null, + }) + ).toMatchObject({ + actualCacheRate: 0.2, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "no_affinity_key", + }); + }); + + test.each(["attempt_failed", "not_observable", "stream_truncated"] as const)( + "keeps observable rates but suppresses coefficient for %s", + (reason) => { + expect( + deriveRequestCacheMetrics({ + ...eligible, + cacheScoreEligible: false, + cacheScoreExcludedReason: reason, + }) + ).toMatchObject({ + actualCacheRate: 0.2, + theoreticalCacheRate: 0.4, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: reason, + }); + } + ); + + test.each(["attempt_failed", "not_observable", "stream_truncated"] as const)( + "preserves %s when the request has no input tokens", + (reason) => { + expect( + deriveRequestCacheMetrics({ + ...eligible, + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cacheScoreEligible: false, + cacheScoreExcludedReason: reason, + }) + ).toMatchObject({ + actualCacheRate: null, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: reason, + }); + } + ); + + test("clamps rates and coefficient to their display bounds", () => { + expect( + deriveRequestCacheMetrics({ + ...eligible, + cacheReadInputTokens: 90, + theoreticalCacheTokens: 10, + }) + ).toMatchObject({ + actualCacheRate: 90 / 210, + theoreticalCacheRate: 10 / 210, + requestCacheCoefficientBp: 10000, + }); + }); + + test("marks requests with no recorded F3b fields without inferring their age", () => { + expect( + deriveRequestCacheMetrics({ + inputTokens: 100, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 20, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + sessionIdentityKind: null, + }) + ).toMatchObject({ + actualCacheRate: 1 / 6, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "not_recorded", + }); + }); + + test("does not treat a Session identity as recorded F3b provenance", () => { + expect( + deriveRequestCacheMetrics({ + inputTokens: 100, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 20, + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + sessionIdentityKind: "session_id", + }) + ).toMatchObject({ + requestCacheMetricAvailability: "not_recorded", + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + }); + }); +}); diff --git a/tests/unit/lib/cache-effectiveness/service.test.ts b/tests/unit/lib/cache-effectiveness/service.test.ts new file mode 100644 index 000000000..e8307032b --- /dev/null +++ b/tests/unit/lib/cache-effectiveness/service.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const txExecute = vi.fn(); + const transaction = vi.fn(async (callback: (tx: { execute: typeof txExecute }) => unknown) => + callback({ execute: txExecute }) + ); + return { txExecute, transaction }; +}); + +vi.mock("server-only", () => ({})); +vi.mock("@/drizzle/db", () => ({ db: { transaction: mocks.transaction } })); +vi.mock("@/lib/logger", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })); + +import { aggregateCacheEffectiveness } from "@/lib/cache-effectiveness/service"; + +function containsDateParameter(value: unknown, seen = new WeakSet()): boolean { + if (value instanceof Date) return true; + if (!value || typeof value !== "object") return false; + if (seen.has(value)) return false; + seen.add(value); + return Object.values(value).some((item) => containsDateParameter(item, seen)); +} + +describe("aggregateCacheEffectiveness", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-02T12:00:00.000Z")); + mocks.txExecute.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("writes the safe delayed window and returns inserted group count", async () => { + mocks.txExecute + .mockResolvedValueOnce([{ acquired: true }]) + .mockResolvedValueOnce([{ last_end: null }]) + .mockResolvedValueOnce([{ id: 1 }, { id: 2 }]); + + const result = await aggregateCacheEffectiveness(); + + expect(result).toMatchObject({ + windowStart: new Date("2026-08-02T11:00:00.000Z"), + windowEnd: new Date("2026-08-02T11:45:00.000Z"), + groupsWritten: 2, + skipped: false, + }); + expect(mocks.txExecute).toHaveBeenCalledTimes(3); + const aggregateSql = mocks.txExecute.mock.calls[2]?.[0]; + expect(containsDateParameter(aggregateSql)).toBe(false); + expect(JSON.stringify(aggregateSql)).toContain("GREATEST"); + }); + + test("skips when another replica owns the advisory lock", async () => { + mocks.txExecute.mockResolvedValueOnce([{ acquired: false }]); + + await expect(aggregateCacheEffectiveness()).resolves.toMatchObject({ + groupsWritten: 0, + skipped: true, + windowStart: null, + windowEnd: null, + }); + expect(mocks.txExecute).toHaveBeenCalledTimes(1); + }); + + test("skips when the persisted window already reaches the safe end", async () => { + mocks.txExecute + .mockResolvedValueOnce([{ acquired: true }]) + .mockResolvedValueOnce([{ last_end: "2026-08-02T11:50:00.000Z" }]); + + await expect(aggregateCacheEffectiveness()).resolves.toMatchObject({ + groupsWritten: 0, + skipped: true, + windowStart: new Date("2026-08-02T11:50:00.000Z"), + windowEnd: new Date("2026-08-02T11:45:00.000Z"), + }); + expect(mocks.txExecute).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/redis/leaderboard-cache.test.ts b/tests/unit/redis/leaderboard-cache.test.ts index 66cb34561..043396803 100644 --- a/tests/unit/redis/leaderboard-cache.test.ts +++ b/tests/unit/redis/leaderboard-cache.test.ts @@ -102,7 +102,7 @@ describe("getLeaderboardWithCache", () => { true ); expect(redis.setex).toHaveBeenCalledWith( - "leaderboard:v3:userCacheHitRate:daily:2026-04-13:tz:UTC:USD:includeModelStats:tags:team-a,vip:groups:group-1", + "leaderboard:v4:userCacheHitRate:daily:2026-04-13:tz:UTC:USD:includeModelStats:tags:team-a,vip:groups:group-1", 60, JSON.stringify(rows) ); @@ -128,7 +128,7 @@ describe("getLeaderboardWithCache", () => { await getLeaderboardWithCache("daily", "USD", "userCacheHitRate"); expect(redis.setex).toHaveBeenCalledWith( - "leaderboard:v3:userCacheHitRate:daily:2026-04-14:tz:Asia/Shanghai:USD", + "leaderboard:v4:userCacheHitRate:daily:2026-04-14:tz:Asia/Shanghai:USD", 60, JSON.stringify(rows) ); diff --git a/tests/unit/repository/leaderboard-cache-coefficient.test.ts b/tests/unit/repository/leaderboard-cache-coefficient.test.ts index 7fe13de34..44b4f32d2 100644 --- a/tests/unit/repository/leaderboard-cache-coefficient.test.ts +++ b/tests/unit/repository/leaderboard-cache-coefficient.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getProviderCacheCoefficients, + getProviderModelCacheCoefficients, resolveLeaderboardWindow, } from "@/repository/provider-cache-effectiveness"; @@ -38,6 +39,20 @@ function aggregateRow( }; } +function modelAggregateRow( + providerId: number, + model: string, + sample: string, + eligible: string, + theoretical: string, + observed: string +) { + return { + ...aggregateRow(providerId, sample, eligible, theoretical, observed), + model, + }; +} + describe("getProviderCacheCoefficients", () => { beforeEach(() => { dbMocks.groupBy.mockResolvedValue([]); @@ -65,6 +80,14 @@ describe("getProviderCacheCoefficients", () => { expect(result.get(1)?.coefficientBp).toBe(7500); }); + it("clamps rawBp at 0 when observed tokens are negative", async () => { + dbMocks.groupBy.mockResolvedValue([aggregateRow(1, "200", "150", "100000", "-100")]); + + const result = await getProviderCacheCoefficients(window); + + expect(result.get(1)?.coefficientBp).toBe(0); + }); + it("returns 0 coefficient when theoretical tokens are zero", async () => { dbMocks.groupBy.mockResolvedValue([aggregateRow(1, "10", "10", "0", "0")]); @@ -97,6 +120,28 @@ describe("getProviderCacheCoefficients", () => { }); }); +describe("getProviderModelCacheCoefficients", () => { + const window = { start: new Date("2026-07-22T00:00:00Z"), end: new Date("2026-07-22T01:00:00Z") }; + + beforeEach(() => { + dbMocks.groupBy.mockResolvedValue([]); + }); + + it("returns independently recomputed coefficients for each provider and model", async () => { + dbMocks.groupBy.mockResolvedValue([ + modelAggregateRow(7, "claude-sonnet", "200", "150", "100000", "86000"), + modelAggregateRow(7, "claude-haiku", "5", "5", "100", "100"), + modelAggregateRow(8, "claude-sonnet", "4", "4", "100", "50"), + ]); + + const result = await getProviderModelCacheCoefficients(window); + + expect(result.get(7)?.get("claude-sonnet")?.coefficientBp).toBe(6450); + expect(result.get(7)?.get("claude-haiku")?.coefficientBp).toBe(3000); + expect(result.get(8)?.get("claude-sonnet")?.coefficientBp).toBe(500); + }); +}); + describe("resolveLeaderboardWindow", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/tests/unit/repository/leaderboard-provider-metrics.test.ts b/tests/unit/repository/leaderboard-provider-metrics.test.ts index 275ae7e4b..24a9a1ef4 100644 --- a/tests/unit/repository/leaderboard-provider-metrics.test.ts +++ b/tests/unit/repository/leaderboard-provider-metrics.test.ts @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ resolveSystemTimezone: vi.fn(), getSystemSettings: vi.fn(), getProviderCacheCoefficients: vi.fn(), + getProviderModelCacheCoefficients: vi.fn().mockResolvedValue(new Map()), })); vi.mock("@/drizzle/db", () => ({ @@ -96,6 +97,7 @@ vi.mock("@/repository/system-config", () => ({ vi.mock("@/repository/provider-cache-effectiveness", () => ({ getProviderCacheCoefficients: mocks.getProviderCacheCoefficients, + getProviderModelCacheCoefficients: mocks.getProviderModelCacheCoefficients, resolveLeaderboardWindow: () => ({ start: new Date(0), end: new Date() }), })); @@ -372,7 +374,7 @@ describe("Provider Leaderboard Model Breakdown", () => { expect(p2!.modelStats![0].model).toBe("model-c"); }); - it("marks model-grain successRate as unavailable when billingModelSource is redirected", async () => { + it("keeps redirected model successRate and joins the model cache coefficient", async () => { chainMocks = [ createChainMock([ { @@ -399,6 +401,24 @@ describe("Provider Leaderboard Model Breakdown", () => { }, ]), ]; + mocks.getProviderModelCacheCoefficients.mockResolvedValue( + new Map([ + [ + 1, + new Map([ + [ + "redirected-model", + { + providerId: 1, + model: "redirected-model", + coefficientBp: 4200, + sampleCount: 10, + }, + ], + ]), + ], + ]) + ); const { findDailyProviderLeaderboard } = await import("@/repository/leaderboard"); const result = await findDailyProviderLeaderboard(undefined, true); @@ -406,13 +426,14 @@ describe("Provider Leaderboard Model Breakdown", () => { expect(modelStat).toMatchObject({ model: "redirected-model", - successRate: null, + successRate: 0.9, rowIdentityBasis: "redirected", - successRateBasis: "unavailable", + successRateBasis: "redirected", costTokensBasis: "redirected", basisDisclosureRequired: true, - successRateUnavailableReason: "redirected_billing_model", + cacheCoefficientBp: 4200, }); + expect(modelStat).not.toHaveProperty("successRateUnavailableReason"); }); }); @@ -472,6 +493,56 @@ describe("Provider Cache Hit Rate Model Breakdown", () => { expect(entry.modelStats[0].model).toBe("claude-3-opus"); }); + it("matches cache coefficients with a normalized model key", async () => { + chainMocks = [ + createChainMock([ + { + providerId: 1, + providerName: "cache-provider", + totalRequests: 10, + totalCost: "1.0", + cacheReadTokens: 500, + cacheCreationCost: "0.2", + totalInputTokens: 1000, + cacheHitRate: 0.5, + }, + ]), + createChainMock([ + { + providerId: 1, + model: " claude-3-opus ", + totalRequests: 10, + cacheReadTokens: 500, + totalInputTokens: 1000, + cacheHitRate: 0.5, + }, + ]), + ]; + mocks.getProviderModelCacheCoefficients.mockResolvedValue( + new Map([ + [ + 1, + new Map([ + [ + "claude-3-opus", + { + providerId: 1, + model: "claude-3-opus", + coefficientBp: 6400, + sampleCount: 10, + }, + ], + ]), + ], + ]) + ); + + const { findDailyProviderCacheHitRateLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyProviderCacheHitRateLeaderboard(); + + expect(result[0]?.modelStats[0]?.cacheCoefficientBp).toBe(6400); + }); + it("falls back to cacheHitRate descending when no provider has a cache coefficient", async () => { chainMocks = [ createChainMock([ @@ -790,7 +861,7 @@ describe("Model Leaderboard basis handling", () => { mocks.getProviderCacheCoefficients.mockResolvedValue(new Map()); }); - it("marks top-level model successRate as unavailable when billingModelSource is redirected", async () => { + it("keeps top-level redirected model successRate", async () => { chainMocks = [ createChainMock([ { @@ -808,12 +879,35 @@ describe("Model Leaderboard basis handling", () => { expect(result[0]).toMatchObject({ model: "redirected-model", - successRate: null, + successRate: 0.8, rowIdentityBasis: "redirected", - successRateBasis: "unavailable", + successRateBasis: "redirected", costTokensBasis: "redirected", basisDisclosureRequired: true, - successRateUnavailableReason: "redirected_billing_model", + }); + expect(result[0]).not.toHaveProperty("successRateUnavailableReason"); + }); + + it("uses no_countable_outcomes only when the SQL success rate is null", async () => { + chainMocks = [ + createChainMock([ + { + model: "redirected-model", + totalRequests: 12, + totalCost: "3.0", + totalTokens: 1200, + successRate: null, + }, + ]), + ]; + + const { findDailyModelLeaderboard } = await import("@/repository/leaderboard"); + const result = await findDailyModelLeaderboard(); + + expect(result[0]).toMatchObject({ + successRate: null, + successRateBasis: "redirected", + successRateUnavailableReason: "no_countable_outcomes", }); }); }); diff --git a/tests/unit/repository/usage-logs-actual-response-model.test.ts b/tests/unit/repository/usage-logs-actual-response-model.test.ts index d3606856d..180256b84 100644 --- a/tests/unit/repository/usage-logs-actual-response-model.test.ts +++ b/tests/unit/repository/usage-logs-actual-response-model.test.ts @@ -102,7 +102,9 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { id: 99, createdAt: new Date("2026-04-24T09:00:00Z"), createdAtRaw: "2026-04-24T09:00:00.000000Z", - sessionId: null, + sessionId: "pfx:scope:ledger", + sourceSessionId: "physical-ledger-session", + sessionIdentityKind: "prefix_affinity", userId: 1, userName: "u", key: "sk-x", @@ -151,7 +153,13 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { id: 99, model: "gemini-2.5-flash", actualResponseModel: "gemini-2.5-flash-lite", + sessionId: "pfx:scope:ledger", + sourceSessionId: "physical-ledger-session", + sessionIdentityKind: "prefix_affinity", }); + + const selection = selectMock.mock.calls[0]?.[0] as Record; + expect(selection.sessionIdentityKind).toBeDefined(); }); test("explicit null actualResponseModel surfaces as null (not undefined)", async () => { @@ -206,4 +214,67 @@ describe("findUsageLogsBatch: actualResponseModel propagation", () => { expect(result.logs[0].actualResponseModel).toBeNull(); }); + + test("readonly ledger path preserves Prefix ID and physical Session ID provenance", async () => { + vi.resetModules(); + + const ledgerRow = { + id: 501, + createdAt: new Date("2026-04-24T08:00:00Z"), + createdAtRaw: "2026-04-24T08:00:00.000000Z", + sessionId: "pfx:scope:readonly", + sourceSessionId: "physical-readonly-session", + sessionIdentityKind: "prefix_affinity", + userId: 1, + userName: "u", + key: "sk-readonly", + keyName: "k", + providerName: "p", + model: "m", + originalModel: "m-original", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 100, + outputTokens: 10, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 30, + cacheCreation5mInputTokens: 20, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: "5m", + costUsd: "0.01", + costMultiplier: null, + groupCostMultiplier: null, + durationMs: 10, + ttftMs: 5, + firstByteMs: 5, + clientIp: null, + context1mApplied: false, + swapCacheTtlApplied: false, + isReplay: false, + replaySourceRequestId: null, + }; + const projections: Array> = []; + const rowsByCall = [[], [ledgerRow]]; + const selectMock = vi.fn((projection: Record) => { + projections.push(projection); + return createThenableQuery(rowsByCall[projections.length - 1] ?? []); + }); + + vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock } })); + + const { findReadonlyUsageLogsBatchForKey } = await import("@/repository/usage-logs"); + const result = await findReadonlyUsageLogsBatchForKey({ + keyString: "sk-readonly", + includeSourceSessionIds: false, + }); + + expect(result.logs[0]).toMatchObject({ + sessionId: "pfx:scope:readonly", + sourceSessionId: "physical-readonly-session", + sessionIdentityKind: "prefix_affinity", + requestCacheMetricAvailability: "not_recorded", + }); + expect(Object.keys(projections[1] ?? {})).toContain("sessionIdentityKind"); + }); }); diff --git a/tests/unit/repository/usage-logs-sessionid-filter.test.ts b/tests/unit/repository/usage-logs-sessionid-filter.test.ts index ca267d53c..1f2d24c70 100644 --- a/tests/unit/repository/usage-logs-sessionid-filter.test.ts +++ b/tests/unit/repository/usage-logs-sessionid-filter.test.ts @@ -294,6 +294,7 @@ describe("Usage logs sessionId filter", () => { createdAtRaw: "2026-03-21T00:00:00.000000Z", sessionId: "pfx:scope:fingerprint", sourceSessionId: "client-old", + sessionIdentityKind: "prefix_affinity", requestSequence: 1, userName: "u", keyName: "k", @@ -303,13 +304,16 @@ describe("Usage logs sessionId filter", () => { actualResponseModel: null, endpoint: "/v1/messages", statusCode: 200, - inputTokens: 1, + inputTokens: 50, outputTokens: 1, cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, + cacheReadInputTokens: 25, cacheCreation5mInputTokens: 0, cacheCreation1hInputTokens: 0, cacheTtlApplied: null, + theoreticalCacheTokens: 50, + cacheScoreEligible: true, + cacheScoreExcludedReason: null, costUsd: "0.01", costMultiplier: null, groupCostMultiplier: null, @@ -354,6 +358,15 @@ describe("Usage logs sessionId filter", () => { const result = await findUsageLogsBatch({ cursor: { createdAt: "2026-03-22", id: 102 } }); expect(result.logs[0]?.sourceSessionIds).toBeUndefined(); + expect(result.logs[0]).toMatchObject({ + sessionIdentityKind: "prefix_affinity", + theoreticalCacheTokens: 50, + cacheScoreEligible: true, + actualCacheRate: 1 / 3, + theoreticalCacheRate: 2 / 3, + requestCacheCoefficientBp: 5000, + requestCacheMetricAvailability: "available", + }); expect(result.sourceSessionIdsByIdentity).toEqual({ "pfx:scope:fingerprint": ["client-new", "client-old"], }); @@ -483,6 +496,132 @@ describe("Usage logs sessionId filter", () => { ); }); + test.each(["offset", "cursor"] as const)( + "key-scoped %s path returns F3b fields and derived cache metrics", + async (mode) => { + vi.resetModules(); + + const projections: Array> = []; + const messageRow = { + id: 301, + createdAt: new Date("2026-03-21T00:00:00Z"), + createdAtRaw: "2026-03-21T00:00:00.000000Z", + sessionIdentityKind: "prefix_affinity", + model: "m", + originalModel: "m-original", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 100, + outputTokens: 10, + costUsd: "0.01", + durationMs: 10, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 30, + cacheCreation5mInputTokens: 20, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: "5m", + theoreticalCacheTokens: 60, + cacheScoreEligible: true, + cacheScoreExcludedReason: null, + isReplay: false, + replaySourceRequestId: null, + specialSettings: null, + }; + const rowsByCall = + mode === "offset" + ? [[messageRow], [], [{ totalRows: 1 }], [{ totalRows: 0 }]] + : [[messageRow], []]; + const selectMock = vi.fn((projection: Record) => { + projections.push(projection); + return createThenableQuery(rowsByCall[projections.length - 1] ?? []); + }); + + vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock } })); + + const repository = await import("@/repository/usage-logs"); + const result = + mode === "offset" + ? await repository.findUsageLogsForKeySlim({ keyString: "key", page: 1, pageSize: 20 }) + : await repository.findUsageLogsForKeyBatch({ + keyString: "key", + cursor: { createdAt: "2026-03-22T00:00:00Z", id: 302 }, + limit: 20, + }); + + expect(result.logs[0]).toMatchObject({ + sessionIdentityKind: "prefix_affinity", + theoreticalCacheTokens: 60, + cacheScoreEligible: true, + cacheScoreExcludedReason: null, + cacheInputTotal: 150, + actualCacheRate: 0.2, + theoreticalCacheRate: 0.4, + requestCacheCoefficientBp: 5000, + requestCacheMetricAvailability: "available", + }); + expect(Object.keys(projections[0] ?? {})).toEqual( + expect.arrayContaining([ + "sessionIdentityKind", + "theoreticalCacheTokens", + "cacheScoreEligible", + "cacheScoreExcludedReason", + ]) + ); + } + ); + + test("key-scoped ledger rows preserve identity kind and report unrecorded F3b metrics", async () => { + vi.resetModules(); + + const projections: Array> = []; + const ledgerRow = { + id: 401, + createdAt: new Date("2026-03-21T00:00:00Z"), + createdAtRaw: "2026-03-21T00:00:00.000000Z", + sessionIdentityKind: "prefix_affinity", + model: "m", + originalModel: "m-original", + actualResponseModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 100, + outputTokens: 10, + costUsd: "0.01", + durationMs: 10, + cacheCreationInputTokens: 20, + cacheReadInputTokens: 30, + cacheCreation5mInputTokens: 20, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: "5m", + isReplay: false, + replaySourceRequestId: null, + }; + const rowsByCall = [[], [ledgerRow]]; + const selectMock = vi.fn((projection: Record) => { + projections.push(projection); + return createThenableQuery(rowsByCall[projections.length - 1] ?? []); + }); + + vi.doMock("@/drizzle/db", () => ({ db: { select: selectMock } })); + + const { findUsageLogsForKeyBatch } = await import("@/repository/usage-logs"); + const result = await findUsageLogsForKeyBatch({ keyString: "key", limit: 20 }); + + expect(result.logs[0]).toMatchObject({ + sessionIdentityKind: "prefix_affinity", + theoreticalCacheTokens: null, + cacheScoreEligible: null, + cacheScoreExcludedReason: null, + cacheInputTotal: 150, + actualCacheRate: 0.2, + theoreticalCacheRate: null, + requestCacheCoefficientBp: null, + requestCacheMetricAvailability: "not_recorded", + }); + expect(Object.keys(projections[1] ?? {})).toContain("sessionIdentityKind"); + }); + test("findUsageLogsWithDetails: sessionId 为空/空白不应追加条件", async () => { vi.resetModules(); diff --git a/tests/unit/usage-logs/export-csv.test.ts b/tests/unit/usage-logs/export-csv.test.ts index 54a85c86b..0970327f9 100644 --- a/tests/unit/usage-logs/export-csv.test.ts +++ b/tests/unit/usage-logs/export-csv.test.ts @@ -50,6 +50,8 @@ const TIME_IDX = 0; const STATUS_IDX = HEADER.indexOf("Status Code"); const COST_IDX = HEADER.indexOf("Cost (USD)"); const DURATION_IDX = HEADER.indexOf("Duration (ms)"); +const PREFIX_ID_IDX = HEADER.indexOf("Prefix ID"); +const SESSION_ID_IDX = HEADER.indexOf("Session ID"); describe("buildCsvHeaderLine", () => { test("annotates the time column with the timezone", () => { @@ -114,6 +116,22 @@ describe("buildCsvRows", () => { ); expect(row.split(",")[retryIdx]).toBe("1"); }); + + test("exports prefix identity separately from the physical Session ID", () => { + const [row] = buildCsvRows( + [ + makeLog({ + sessionId: "pfx:scope/root", + sourceSessionId: "physical-session", + sessionIdentityKind: "prefix_affinity", + }), + ], + "UTC" + ); + const cells = row.split(","); + expect(cells[PREFIX_ID_IDX]).toBe("pfx:scope/root"); + expect(cells[SESSION_ID_IDX]).toBe("physical-session"); + }); }); describe("escapeCsvField", () => { diff --git a/tests/unit/usage-logs/export-xlsx.test.ts b/tests/unit/usage-logs/export-xlsx.test.ts index 3bd0d9e6d..530aefbe1 100644 --- a/tests/unit/usage-logs/export-xlsx.test.ts +++ b/tests/unit/usage-logs/export-xlsx.test.ts @@ -1,6 +1,7 @@ import { strFromU8, unzipSync } from "fflate"; import { describe, expect, test } from "vitest"; import type { UsageLogRow } from "@/repository/usage-logs"; +import { buildDetailHeaders } from "@/lib/usage-logs/export/columns"; import { buildUsageLogsXlsx, columnRef } from "@/lib/usage-logs/export/xlsx"; function makeLog(overrides: Partial = {}): UsageLogRow { @@ -65,6 +66,9 @@ const COST_COL = columnRef(14); // O const TIME_COL = columnRef(0); // A const MODEL_COL = columnRef(4); // E const STATUS_COL = columnRef(7); // H +const HEADER = buildDetailHeaders("UTC"); +const PREFIX_ID_COL = columnRef(HEADER.indexOf("Prefix ID")); +const SESSION_ID_COL = columnRef(HEADER.indexOf("Session ID")); describe("buildUsageLogsXlsx", () => { test("produces a valid two-sheet workbook package", async () => { @@ -114,6 +118,26 @@ describe("buildUsageLogsXlsx", () => { expect(statusCell).not.toContain("inlineStr"); }); + test("exports prefix identity separately from the physical Session ID", async () => { + const files = unzip( + await buildUsageLogsXlsx( + [ + makeLog({ + sessionId: "pfx:scope/root", + sourceSessionId: "physical-session", + sessionIdentityKind: "prefix_affinity", + }), + ], + "UTC" + ) + ); + const sheet1 = files["xl/worksheets/sheet1.xml"]; + const prefixCell = cell(sheet1, `${PREFIX_ID_COL}2`) ?? ""; + const sessionCell = cell(sheet1, `${SESSION_ID_COL}2`) ?? ""; + expect(prefixCell).toContain("pfx:scope/root"); + expect(sessionCell).toContain("physical-session"); + }); + test("timestamp is a real Excel date serial reflecting the system timezone", async () => { const files = unzip(await buildUsageLogsXlsx([makeLog()], "Asia/Shanghai")); const sheet1 = files["xl/worksheets/sheet1.xml"];