diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 9c62d662306..246791f2843 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -144,7 +144,13 @@ 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. Requests +without a matching price or usable usage are counted as excluded beside that amount when the proxy +reports pricing coverage fields. A row with only excluded requests shows an em dash with that count +instead of a zero-dollar estimate, and a displayed zero-dollar value is a confirmed priced estimate. +An older proxy that reports no coverage fields keeps a bare em dash with no count, so missing +coverage stays distinct from an all-excluded row and from a confirmed zero estimate. +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 00000000000..9a76653989f 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 dd5ca2633c1..a98f0b1de0f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -796,6 +796,8 @@ 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} ohne Preis oder Nutzungsdaten)", + "usage.cost.excludedOne": "({count} ohne Preis oder Nutzungsdaten)", "logs.detail.section.basic": "Grundinformationen", "logs.detail.route.section": "Route-Entscheidung", "logs.detail.route.kind": "Route-Typ", @@ -944,6 +946,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 6723d74b815..f738334aea8 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -845,6 +845,8 @@ 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)", + "usage.cost.excludedOne": "({count} request excluded)", "logs.detail.section.basic": "Basic information", "logs.detail.route.section": "Route decision", "logs.detail.route.kind": "Route kind", @@ -997,6 +999,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 43d956906fd..6340b01a0a8 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -826,6 +826,8 @@ 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 faute de tarif ou de données d’utilisation)", + "usage.cost.excludedOne": "({count} requête exclue faute de tarif ou de données d’utilisation)", "logs.detail.section.basic": "Informations générales", "logs.detail.route.section": "Décision de routage", "logs.detail.route.kind": "Type de route", @@ -974,6 +976,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 d60148bb603..4989033e86d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -758,6 +758,8 @@ export const ja: Record = { "usage.cost.total": "API 定価相当額(この期間)", "usage.cost.disclaimer": "請求明細ではありません。サブスクリプション利用量やプロバイダークレジットが代わりに適用される場合があります。", "usage.cost.unpricedNote": "{count} 件のリクエストを除外(価格または使用量なし)", + "usage.cost.excluded": "({count} 件のリクエストを除外)", + "usage.cost.excludedOne": "({count} 件のリクエストを除外)", "logs.detail.section.basic": "基本情報", "logs.detail.route.section": "ルート決定", "logs.detail.route.kind": "ルート種別", @@ -910,6 +912,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 be26daf40ed..e9e9bc08ade 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -827,6 +827,8 @@ export const ko: Record = { "usage.cost.total": "API 정가 환산치 (이 기간)", "usage.cost.disclaimer": "결제 영수증이 아닙니다. 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다.", "usage.cost.unpricedNote": "비용 산정 불가 {count}건 제외", + "usage.cost.excluded": "({count}건 요청 제외)", + "usage.cost.excludedOne": "({count}건 요청 제외)", "logs.detail.section.basic": "기본 정보", "logs.detail.route.section": "라우팅 결정", "logs.detail.route.kind": "라우팅 종류", @@ -978,6 +980,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 9691f8a49f4..239784d355d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -813,6 +813,8 @@ export const ru: Record = { "usage.cost.total": "Эквивалент стоимости по прайс-листу API (за этот период)", "usage.cost.disclaimer": "Не является счётом. Расходы могут покрываться подпиской или кредитами провайдера.", "usage.cost.unpricedNote": "Исключено {count} запросов (нет цены или данных использования)", + "usage.cost.excluded": "({count} исключено)", + "usage.cost.excludedOne": "({count} исключено)", "logs.detail.section.basic": "Основная информация", "logs.detail.route.section": "Решение о маршруте", "logs.detail.route.kind": "Тип маршрута", @@ -965,6 +967,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 95336fa32fd..8dd4d98c1ae 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -832,6 +832,8 @@ 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)", + "usage.cost.excludedOne": "({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ü", @@ -984,6 +986,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 19f1e50ce27..b53b4fc5361 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -662,6 +662,8 @@ export const zhTW: Record = { "usage.cost.total": "API 標價折算(當前範圍)", "usage.cost.disclaimer": "這不是帳單或扣費憑證。實際可能計入訂閱用量或消耗服務商額度。", "usage.cost.unpricedNote": "已排除 {count} 個無法計費的請求", + "usage.cost.excluded": "(已排除 {count} 個請求)", + "usage.cost.excludedOne": "(已排除 {count} 個請求)", "logs.detail.section.basic": "基本資訊", "logs.detail.section.performance": "效能", "logs.detail.section.cost": "API 標價折算", @@ -784,6 +786,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 e081d027f3f..27c92df0d74 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -808,6 +808,8 @@ export const zh: Record = { "usage.cost.total": "API 标价折算(当前范围)", "usage.cost.disclaimer": "这不是账单或扣费凭证。实际可能计入订阅用量或消耗服务商额度。", "usage.cost.unpricedNote": "已排除 {count} 个无法计费的请求", + "usage.cost.excluded": "(已排除 {count} 个请求)", + "usage.cost.excludedOne": "(已排除 {count} 个请求)", "logs.detail.section.basic": "基本信息", "logs.detail.route.section": "路由决策", "logs.detail.route.kind": "路由类型", @@ -959,6 +961,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 1b655f8cc91..de96b16e115 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -67,6 +67,12 @@ interface UsageModel { totalTokens: number; inputTokens: number; outputTokens: number; + /** API list-price estimate for the priced portion of this row. */ + estimatedCostUsd?: number; + /** Requests included in the API list-price estimate. */ + pricedRequests?: number; + /** Requests excluded from the estimate because price or usable usage is unavailable. */ + unpricedRequests?: number; shareRatio: number; } @@ -77,6 +83,12 @@ interface UsageProvider { reportedRequests: number; estimatedRequests: number; totalTokens: number; + /** API list-price estimate for the priced portion of this row. */ + estimatedCostUsd?: number; + /** Requests included in the API list-price estimate. */ + pricedRequests?: number; + /** Requests excluded from the estimate because price or usable usage is unavailable. */ + unpricedRequests?: number; shareRatio: number; } @@ -109,6 +121,45 @@ function formatPct(ratio: number): string { return `${Math.round(ratio * 100)}%`; } +type UsageCostRow = Pick; + +/** + * Renders a row's API list-price estimate with explicit pricing coverage. + * Newer proxies return the coverage fields even when every request is + * unpriced; older proxies have none of them, so their cells stay unavailable + * rather than making an unknown amount look free. + */ +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; + const excludedCaption = t( + excludedRequests === 1 ? "usage.cost.excludedOne" : "usage.cost.excluded", + { count: excludedRequests }, + ); + if (row.estimatedCostUsd === undefined) { + return ( + <> + + {excludedRequests > 0 && ( + {excludedCaption} + )} + + ); + } + return ( + <> + {formatUsdEstimate(row.estimatedCostUsd ?? 0, locale)} + {excludedRequests > 0 && ( + {excludedCaption} + )} + + ); +} + // 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 { @@ -643,6 +694,7 @@ function UsageModelsTable({ const searchLabel = t("usage.search.models"); const sectionLabel = t("usage.section.models"); const titleId = "usage-models-title"; + const listPriceDisclaimerId = "usage-models-list-price-disclaimer"; const searchInput = ( {t("usage.col.requests")} {t("usage.col.measured")} {t("usage.col.tokens")} + {t("usage.col.apiListPrice")} {t("usage.col.share")} @@ -673,11 +726,13 @@ function UsageModelsTable({ {model.requests} {model.measuredRequests} {formatTokens(model.totalTokens, locale)} +
))} +

{t("usage.cost.disclaimer")}

); @@ -714,6 +769,7 @@ function UsageProvidersTable({ }) { const sectionLabel = t("usage.section.providers"); const titleId = "usage-providers-title"; + const listPriceDisclaimerId = "usage-providers-list-price-disclaimer"; const table = (
@@ -723,6 +779,7 @@ function UsageProvidersTable({ + @@ -733,11 +790,13 @@ function UsageProvidersTable({ + ))}
{t("usage.col.requests")} {t("usage.col.measured")} {t("usage.col.tokens")}{t("usage.col.apiListPrice")} {t("usage.col.share")}
{provider.requests} {provider.measuredRequests} {formatTokens(provider.totalTokens, locale)}
+

{t("usage.cost.disclaimer")}

); diff --git a/gui/tests/usage-layout.test.ts b/gui/tests/usage-layout.test.ts index 922bf9baf56..c7d4cecd604 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -2,7 +2,11 @@ import { expect, test } from "bun:test"; import { Window } from "happy-dom"; import { act, createElement } from "react"; import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { de } from "../src/i18n/de"; +import { fr } from "../src/i18n/fr"; import { LanguageProvider } from "../src/i18n/provider"; +import { interpolate } from "../src/i18n/shared"; +import { ru } from "../src/i18n/ru"; import Usage from "../src/pages/Usage"; test("Usage renders every section in one scrollable column with a sticky strip", async () => { @@ -84,6 +88,127 @@ 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 distinguish priced zero totals from excluded requests", async () => { + const globalKeys = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const; + const previous = new Map(globalKeys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + const originalFetch = Object.getOwnPropertyDescriptor(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: 5, + measuredRequests: 5, + reportedRequests: 5, + 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: "zero-priced", model: "zero-priced-model", requests: 1, measuredRequests: 1, reportedRequests: 1, estimatedRequests: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, estimatedCostUsd: 0, pricedRequests: 1, unpricedRequests: 0, shareRatio: 0 }, + { provider: "unpriced-single", model: "unpriced-single-model", requests: 1, measuredRequests: 1, reportedRequests: 1, estimatedRequests: 0, totalTokens: 10, inputTokens: 10, outputTokens: 0, pricedRequests: 0, unpricedRequests: 1, shareRatio: 0 }, + { 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 }, + { provider: "coverage-without-estimate", model: "coverage-without-estimate-model", requests: 1, measuredRequests: 0, reportedRequests: 0, estimatedRequests: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, pricedRequests: 0, unpricedRequests: 0, shareRatio: 0 }, + ], + providers: [ + { provider: "priced", requests: 1, measuredRequests: 1, reportedRequests: 1, estimatedRequests: 0, totalTokens: 100, estimatedCostUsd: 1.25, pricedRequests: 1, unpricedRequests: 0, shareRatio: 2 / 3 }, + { provider: "zero-priced", requests: 1, measuredRequests: 1, reportedRequests: 1, estimatedRequests: 0, totalTokens: 0, estimatedCostUsd: 0, pricedRequests: 1, unpricedRequests: 0, shareRatio: 0 }, + { provider: "unpriced-single", requests: 1, measuredRequests: 1, reportedRequests: 1, estimatedRequests: 0, totalTokens: 10, pricedRequests: 0, unpricedRequests: 1, shareRatio: 0 }, + { provider: "unpriced", requests: 3, measuredRequests: 3, reportedRequests: 3, estimatedRequests: 0, totalTokens: 50, pricedRequests: 0, unpricedRequests: 3, shareRatio: 1 / 3 }, + { provider: "coverage-without-estimate", requests: 1, measuredRequests: 0, reportedRequests: 0, estimatedRequests: 0, totalTokens: 0, pricedRequests: 0, unpricedRequests: 0, shareRatio: 0 }, + ], + 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"); + expect(container.textContent).toContain("— (1 request excluded)"); + expect(container.textContent).toContain("— (3 requests excluded)"); + expect([...container.querySelectorAll("th")].filter(cell => cell.textContent === "API list-price")).toHaveLength(2); + expect(container.querySelector('th[aria-describedby="usage-models-list-price-disclaimer"]')?.textContent).toBe("API list-price"); + expect(container.querySelector('th[aria-describedby="usage-providers-list-price-disclaimer"]')?.textContent).toBe("API list-price"); + const unavailableModelRow = [...container.querySelectorAll("tr")].find(row => row.textContent?.includes("coverage-without-estimate-model")); + expect(unavailableModelRow?.textContent).toContain("—"); + expect(unavailableModelRow?.textContent).not.toContain("~$0.0000"); + expect(unavailableModelRow?.textContent).not.toContain("0 requests excluded"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + if (originalFetch) Object.defineProperty(globalThis, "fetch", originalFetch); + else Reflect.deleteProperty(globalThis, "fetch"); + clearClientResourceStoresForTests(); + testWindow.close(); + for (const key of globalKeys) { + const descriptor = previous.get(key); + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } +}); + +test("Russian excluded-request caption stays grammatical for common counts", () => { + for (const count of [1, 2, 5, 21]) { + expect(interpolate(ru["usage.cost.excluded"], { count })).toBe(`(${count} исключено)`); + } +}); + +test("German captions remain count-neutral and French captions distinguish singular", () => { + for (const count of [1, 2, 5]) { + expect(interpolate(de["usage.cost.excluded"], { count })).toBe(`(${count} ohne Preis oder Nutzungsdaten)`); + } + expect(interpolate(fr["usage.cost.excludedOne"], { count: 1 })).toBe("(1 requête exclue faute de tarif ou de données d’utilisation)"); + for (const count of [2, 5]) { + expect(interpolate(fr["usage.cost.excluded"], { count })).toBe(`(${count} requêtes exclues faute de tarif ou de données d’utilisation)`); } });