diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 72adcdcd70..d64e559afa 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -138,7 +138,9 @@ For a custom usage interval, the server must confirm the exact requested start a If an older running proxy does not support those bounds, the dashboard and CLI reject its report; upgrade and restart that proxy before retrying. Resetting a manual model price affects only that model, preserving other rates saved independently. -They are not billing receipts or evidence of an actual charge; subscription usage or provider credits +The **Usage** Models and Providers tables show the estimated priced portion for each row; any +requests without a matching price or usable usage are counted as excluded beside that amount. +These are not billing receipts or evidence of an actual charge; subscription usage or provider credits may apply instead. Provider model rows may include **unresolved requested model usage**: the saved route sent the diff --git a/docs/pr-assets/usage-api-list-price.png b/docs/pr-assets/usage-api-list-price.png new file mode 100644 index 0000000000..9473d41cc8 Binary files /dev/null and b/docs/pr-assets/usage-api-list-price.png differ diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 40ee6ea36f..482fad58d7 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -752,6 +752,7 @@ export const de: Record = { "usage.cost.total": "API-Listenpreis-Äquivalent (dieser Zeitraum)", "usage.cost.disclaimer": "Kein Abrechnungsbeleg. Stattdessen können Abonnementnutzung oder Anbieter-Guthaben gelten.", "usage.cost.unpricedNote": "{count} Anfragen ohne Preis oder Nutzung ausgeschlossen", + "usage.cost.excluded": "({count} Anfragen ausgeschlossen)", "logs.detail.section.basic": "Grundinformationen", "logs.detail.route.section": "Route-Entscheidung", "logs.detail.route.kind": "Route-Typ", @@ -896,6 +897,7 @@ export const de: Record = { "usage.col.measured": "Gemessen", "usage.col.reported": "Gemeldet", "usage.col.tokens": "Tokens", + "usage.col.apiListPrice": "API-Listenpreis", "usage.col.share": "Anteil", "usage.heatmap.less": "Weniger", "usage.heatmap.more": "Mehr", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 53d940d7e6..6f3ea56b11 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -799,6 +799,7 @@ export const en = { "usage.cost.total": "API list-price equivalent (this range)", "usage.cost.disclaimer": "Not a billing receipt. Subscription usage or provider credits may apply instead.", "usage.cost.unpricedNote": "{count} requests excluded (no price or usage)", + "usage.cost.excluded": "({count} requests excluded)", "logs.detail.section.basic": "Basic information", "logs.detail.route.section": "Route decision", "logs.detail.route.kind": "Route kind", @@ -947,6 +948,7 @@ export const en = { "usage.col.measured": "Measured", "usage.col.reported": "Reported", "usage.col.tokens": "Tokens", + "usage.col.apiListPrice": "API list-price", "usage.col.share": "Share", "usage.heatmap.less": "Less", "usage.heatmap.more": "More", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 7afb44ab2c..3f23726314 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -780,6 +780,7 @@ export const fr: Record = { "usage.cost.total": "Équivalent au tarif catalogue de l’API (cette période)", "usage.cost.disclaimer": "Ceci n’est pas un reçu de facturation. L’utilisation d’un abonnement ou les crédits du fournisseur peuvent s’appliquer à la place.", "usage.cost.unpricedNote": "{count} requêtes exclues (aucun tarif ni donnée d’utilisation)", + "usage.cost.excluded": "({count} requêtes exclues)", "logs.detail.section.basic": "Informations générales", "logs.detail.route.section": "Décision de routage", "logs.detail.route.kind": "Type de route", @@ -924,6 +925,7 @@ export const fr: Record = { "usage.col.measured": "Mesurées", "usage.col.reported": "Communiquées", "usage.col.tokens": "Jetons", + "usage.col.apiListPrice": "Tarif catalogue API", "usage.col.share": "Part", "usage.heatmap.less": "Moins", "usage.heatmap.more": "Plus", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 75095da332..4cc7aa3595 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -714,6 +714,7 @@ export const ja: Record = { "usage.cost.total": "API 定価相当額(この期間)", "usage.cost.disclaimer": "請求明細ではありません。サブスクリプション利用量やプロバイダークレジットが代わりに適用される場合があります。", "usage.cost.unpricedNote": "{count} 件のリクエストを除外(価格または使用量なし)", + "usage.cost.excluded": "({count} 件のリクエストを除外)", "logs.detail.section.basic": "基本情報", "logs.detail.route.section": "ルート決定", "logs.detail.route.kind": "ルート種別", @@ -862,6 +863,7 @@ export const ja: Record = { "usage.col.measured": "計測", "usage.col.reported": "報告", "usage.col.tokens": "トークン", + "usage.col.apiListPrice": "API 定価", "usage.col.share": "割合", "usage.heatmap.less": "少ない", "usage.heatmap.more": "多い", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5ae812e9e8..8db22d7696 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -783,6 +783,7 @@ export const ko: Record = { "usage.cost.total": "API 정가 환산치 (이 기간)", "usage.cost.disclaimer": "결제 영수증이 아닙니다. 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다.", "usage.cost.unpricedNote": "비용 산정 불가 {count}건 제외", + "usage.cost.excluded": "({count}건 요청 제외)", "logs.detail.section.basic": "기본 정보", "logs.detail.route.section": "라우팅 결정", "logs.detail.route.kind": "라우팅 종류", @@ -930,6 +931,7 @@ export const ko: Record = { "usage.col.measured": "측정됨", "usage.col.reported": "측정됨", "usage.col.tokens": "토큰", + "usage.col.apiListPrice": "API 정가", "usage.col.share": "비율", "usage.heatmap.less": "적음", "usage.heatmap.more": "많음", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2900abb6f3..8e55dae403 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -769,6 +769,7 @@ export const ru: Record = { "usage.cost.total": "Эквивалент стоимости по прайс-листу API (за этот период)", "usage.cost.disclaimer": "Не является счётом. Расходы могут покрываться подпиской или кредитами провайдера.", "usage.cost.unpricedNote": "Исключено {count} запросов (нет цены или данных использования)", + "usage.cost.excluded": "({count} запросов исключено)", "logs.detail.section.basic": "Основная информация", "logs.detail.route.section": "Решение о маршруте", "logs.detail.route.kind": "Тип маршрута", @@ -917,6 +918,7 @@ export const ru: Record = { "usage.col.measured": "Измерено", "usage.col.reported": "Сообщено", "usage.col.tokens": "Токены", + "usage.col.apiListPrice": "Прайс-лист API", "usage.col.share": "Доля", "usage.heatmap.less": "Меньше", "usage.heatmap.more": "Больше", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f551a97d43..853810acde 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -788,6 +788,7 @@ export const tr: Record = { "usage.cost.total": "API liste fiyatı eşdeğeri", "usage.cost.disclaimer": "Fatura makbuzu değildir.", "usage.cost.unpricedNote": "{count} istek hariç tutuldu", + "usage.cost.excluded": "({count} istek hariç tutuldu)", "logs.detail.section.basic": "Temel bilgiler", "logs.detail.route.section": "Yönlendirme kararı", "logs.detail.route.kind": "Yönlendirme türü", @@ -936,6 +937,7 @@ export const tr: Record = { "usage.col.measured": "Ölçülen", "usage.col.reported": "Bildirilen", "usage.col.tokens": "Jetonlar", + "usage.col.apiListPrice": "API liste fiyatı", "usage.col.share": "Pay", "usage.heatmap.less": "Daha az", "usage.heatmap.more": "Daha fazla", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ae0ee0517b..0bc80565a8 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -618,6 +618,7 @@ export const zhTW: Record = { "usage.cost.total": "API 標價折算(當前範圍)", "usage.cost.disclaimer": "這不是帳單或扣費憑證。實際可能計入訂閱用量或消耗服務商額度。", "usage.cost.unpricedNote": "已排除 {count} 個無法計費的請求", + "usage.cost.excluded": "(已排除 {count} 個請求)", "logs.detail.section.basic": "基本資訊", "logs.detail.section.performance": "效能", "logs.detail.section.cost": "API 標價折算", @@ -737,6 +738,7 @@ export const zhTW: Record = { "usage.col.measured": "已計量", "usage.col.reported": "已上報", "usage.col.tokens": "Token 數", + "usage.col.apiListPrice": "API 標價", "usage.col.share": "佔比", "usage.heatmap.less": "少", "usage.heatmap.more": "多", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ca92aaeafc..c24fc6e914 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -764,6 +764,7 @@ export const zh: Record = { "usage.cost.total": "API 标价折算(当前范围)", "usage.cost.disclaimer": "这不是账单或扣费凭证。实际可能计入订阅用量或消耗服务商额度。", "usage.cost.unpricedNote": "已排除 {count} 个无法计费的请求", + "usage.cost.excluded": "(已排除 {count} 个请求)", "logs.detail.section.basic": "基本信息", "logs.detail.route.section": "路由决策", "logs.detail.route.kind": "路由类型", @@ -911,6 +912,7 @@ export const zh: Record = { "usage.col.measured": "已计量", "usage.col.reported": "已上报", "usage.col.tokens": "Token 数", + "usage.col.apiListPrice": "API 标价", "usage.col.share": "占比", "usage.heatmap.less": "少", "usage.heatmap.more": "多", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index cfcf00e578..a81e4eca83 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -63,6 +63,11 @@ interface UsageModel { totalTokens: number; inputTokens: number; outputTokens: number; + /** API list-price estimate for the priced portion of this row. */ + estimatedCostUsd?: number; + pricedRequests?: number; + /** Requests excluded from the estimate because price or usable usage is unavailable. */ + unpricedRequests?: number; shareRatio: number; } @@ -73,6 +78,11 @@ interface UsageProvider { reportedRequests: number; estimatedRequests: number; totalTokens: number; + /** API list-price estimate for the priced portion of this row. */ + estimatedCostUsd?: number; + pricedRequests?: number; + /** Requests excluded from the estimate because price or usable usage is unavailable. */ + unpricedRequests?: number; shareRatio: number; } @@ -105,6 +115,30 @@ function formatPct(ratio: number): string { return `${Math.round(ratio * 100)}%`; } +type UsageCostRow = Pick; + +/** + * Newer proxies return the per-row coverage fields even when every request is + * unpriced. Older proxies have none of them, so keep their cells unavailable + * rather than making an unknown amount look like a free request. + */ +function UsageListPrice({ row, locale, t }: { row: UsageCostRow; locale: Locale; t: TFn }) { + const hasPriceData = row.estimatedCostUsd !== undefined + || row.pricedRequests !== undefined + || row.unpricedRequests !== undefined; + if (!hasPriceData) return ; + + const excludedRequests = row.unpricedRequests ?? 0; + return ( + <> + {formatUsdEstimate(row.estimatedCostUsd ?? 0, locale)} + {excludedRequests > 0 && ( + {t("usage.cost.excluded", { count: excludedRequests })} + )} + + ); +} + // Stable per-model bar color: hash the provider/model id to a hue so the same model keeps its color // across days and renders. Saturation/lightness are fixed for a cohesive palette on the dark chart. function modelColor(model: string, provider: string): string { @@ -550,6 +584,7 @@ function UsageModelsTable({ {t("usage.col.requests")} {t("usage.col.measured")} {t("usage.col.tokens")} + {t("usage.col.apiListPrice")} {t("usage.col.share")} @@ -561,6 +596,7 @@ function UsageModelsTable({ {model.requests} {model.measuredRequests} {formatTokens(model.totalTokens, locale)} +
))} @@ -611,6 +647,7 @@ function UsageProvidersTable({ {t("usage.col.requests")} {t("usage.col.measured")} {t("usage.col.tokens")} + {t("usage.col.apiListPrice")} {t("usage.col.share")} @@ -621,6 +658,7 @@ function UsageProvidersTable({ {provider.requests} {provider.measuredRequests} {formatTokens(provider.totalTokens, locale)} +
))} diff --git a/gui/tests/usage-layout.test.ts b/gui/tests/usage-layout.test.ts index 922bf9baf5..be3bde7fa8 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -84,6 +84,94 @@ test("usage workspace i18n keys exist in every locale", async () => { expect(dict).toContain('"usage.scope.machine":'); expect(dict).toContain('"usage.scope.hub":'); expect(dict).toContain('"usage.hubOffline":'); + expect(dict).toContain('"usage.col.apiListPrice":'); + expect(dict).toContain('"usage.cost.excluded":'); + } +}); + +test("Usage breakdown tables show priced totals and excluded requests without calling them free", async () => { + const globalKeys = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const; + const previous = Object.fromEntries(globalKeys.map(key => [key, Reflect.get(globalThis, key)])); + const originalFetch = globalThis.fetch; + const testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + class ResizeObserverStub { + constructor(_callback: ResizeObserverCallback) {} + observe() {} + unobserve() {} + disconnect() {} + } + Object.defineProperty(globalThis, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); + Object.defineProperty(testWindow, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); + clearClientResourceStoresForTests(); + globalThis.fetch = (async () => Response.json({ + range: "30d", + surface: "all", + since: null, + generatedAt: Date.now(), + summary: { + requests: 4, + measuredRequests: 4, + reportedRequests: 4, + unreportedRequests: 0, + unsupportedRequests: 0, + estimatedRequests: 0, + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 150, + coverageRatio: 1, + }, + days: [], + models: [ + { provider: "priced", model: "priced-model", requests: 1, measuredRequests: 1, reportedRequests: 1, estimatedRequests: 0, totalTokens: 100, inputTokens: 50, outputTokens: 50, estimatedCostUsd: 1.25, pricedRequests: 1, unpricedRequests: 0, shareRatio: 2 / 3 }, + { provider: "unpriced", model: "unpriced-model", requests: 3, measuredRequests: 3, reportedRequests: 3, estimatedRequests: 0, totalTokens: 50, inputTokens: 50, outputTokens: 0, pricedRequests: 0, unpricedRequests: 3, shareRatio: 1 / 3 }, + ], + providers: [ + { provider: "priced", requests: 1, measuredRequests: 1, reportedRequests: 1, estimatedRequests: 0, totalTokens: 100, estimatedCostUsd: 1.25, pricedRequests: 1, unpricedRequests: 0, shareRatio: 2 / 3 }, + { provider: "unpriced", requests: 3, measuredRequests: 3, reportedRequests: 3, estimatedRequests: 0, totalTokens: 50, pricedRequests: 0, unpricedRequests: 3, shareRatio: 1 / 3 }, + ], + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + })) as typeof fetch; + + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + const root = createRoot(container); + try { + await act(async () => { + root.render(createElement(LanguageProvider, null, createElement(Usage, { apiBase: "http://usage-list-price-test" }))); + }); + const deadline = Date.now() + 1_000; + while (!(container.textContent ?? "").includes("(3 requests excluded)")) { + if (Date.now() >= deadline) throw new Error("Usage list-price cells did not render"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } + + expect(container.textContent).toContain("~$1.2500"); + expect(container.textContent).toContain("~$0.0000 (3 requests excluded)"); + expect([...container.querySelectorAll("th")].filter(cell => cell.textContent === "API list-price")).toHaveLength(2); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globalKeys) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } } });