From 07e2d633cc3352500e495765840631dbf101cfd3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Jul 2026 04:53:09 +0100 Subject: [PATCH] feat: show auto-detected provider plans and billing (#100520) * feat(providers): auto-discover usage billing * feat(ui): show provider plans and billing * fix(ui): translate provider billing labels --- docs/concepts/usage-tracking.md | 35 ++- docs/plugins/manifest.md | 4 + docs/plugins/sdk-provider-plugins.md | 18 ++ docs/web/control-ui.md | 6 + extensions/anthropic/openclaw.plugin.json | 3 +- extensions/clawrouter/openclaw.plugin.json | 3 + extensions/clawrouter/usage.test.ts | 11 + extensions/clawrouter/usage.ts | 20 +- extensions/deepseek/openclaw.plugin.json | 3 + .../github-copilot/openclaw.plugin.json | 3 +- extensions/google/openclaw.plugin.json | 3 +- extensions/minimax/openclaw.plugin.json | 3 +- extensions/openai/openclaw.plugin.json | 3 +- extensions/openrouter/index.test.ts | 4 + extensions/openrouter/index.ts | 13 + extensions/openrouter/openclaw.plugin.json | 3 +- extensions/openrouter/usage.test.ts | 233 ++++++++++++++++++ extensions/openrouter/usage.ts | 219 ++++++++++++++++ extensions/venice/index.test.ts | 9 + extensions/venice/index.ts | 13 + extensions/venice/openclaw.plugin.json | 3 + extensions/venice/usage.test.ts | 95 +++++++ extensions/venice/usage.ts | 128 ++++++++++ extensions/xiaomi/openclaw.plugin.json | 3 +- extensions/zai/openclaw.plugin.json | 3 +- scripts/plugin-sdk-surface-report.mjs | 2 +- src/auto-reply/reply/commands-status.test.ts | 39 +++ src/infra/provider-usage.fetch.claude.test.ts | 39 +++ src/infra/provider-usage.fetch.claude.ts | 60 +++++ src/infra/provider-usage.fetch.codex.test.ts | 15 +- src/infra/provider-usage.fetch.codex.ts | 10 +- .../provider-usage.fetch.deepseek.test.ts | 5 + src/infra/provider-usage.fetch.deepseek.ts | 14 ++ src/infra/provider-usage.fetch.shared.ts | 4 +- src/infra/provider-usage.format.test.ts | 46 ++++ src/infra/provider-usage.format.ts | 47 +++- src/infra/provider-usage.load.ts | 40 ++- src/infra/provider-usage.shared.test.ts | 4 +- src/infra/provider-usage.shared.ts | 25 +- src/infra/provider-usage.ts | 1 + src/infra/provider-usage.types.ts | 40 ++- src/plugin-sdk/provider-usage.ts | 1 + .../installed-plugin-index-scope-lookup.ts | 1 + src/plugins/manifest-registry.test.ts | 7 +- src/plugins/manifest-registry.ts | 2 + src/plugins/manifest.ts | 4 + src/plugins/provider-runtime.test.ts | 44 ++++ src/plugins/provider-runtime.ts | 40 +++ src/plugins/providers.test.ts | 23 ++ src/plugins/providers.ts | 48 ++++ src/status/codex-synthetic-usage.test.ts | 78 ++++++ src/status/codex-synthetic-usage.ts | 64 ++++- src/status/status-text.ts | 4 +- ui/src/e2e/usage-cost-analysis.e2e.test.ts | 52 +++- ui/src/i18n/.i18n/ar.meta.json | 8 +- ui/src/i18n/.i18n/de.meta.json | 8 +- ui/src/i18n/.i18n/es.meta.json | 8 +- ui/src/i18n/.i18n/fa.meta.json | 8 +- ui/src/i18n/.i18n/fr.meta.json | 8 +- ui/src/i18n/.i18n/hi.meta.json | 12 +- ui/src/i18n/.i18n/hi.tm.jsonl | 1 + ui/src/i18n/.i18n/id.meta.json | 8 +- ui/src/i18n/.i18n/it.meta.json | 8 +- ui/src/i18n/.i18n/ja-JP.meta.json | 8 +- ui/src/i18n/.i18n/ko.meta.json | 8 +- ui/src/i18n/.i18n/nl.meta.json | 8 +- ui/src/i18n/.i18n/pl.meta.json | 8 +- ui/src/i18n/.i18n/pt-BR.meta.json | 8 +- ui/src/i18n/.i18n/ru.meta.json | 12 +- ui/src/i18n/.i18n/ru.tm.jsonl | 1 + ui/src/i18n/.i18n/th.meta.json | 8 +- ui/src/i18n/.i18n/tr.meta.json | 8 +- ui/src/i18n/.i18n/uk.meta.json | 8 +- ui/src/i18n/.i18n/vi.meta.json | 8 +- ui/src/i18n/.i18n/zh-CN.meta.json | 8 +- ui/src/i18n/.i18n/zh-TW.meta.json | 8 +- ui/src/i18n/locales/ar.ts | 9 + ui/src/i18n/locales/de.ts | 10 + ui/src/i18n/locales/en.ts | 9 + ui/src/i18n/locales/es.ts | 10 + ui/src/i18n/locales/fa.ts | 9 + ui/src/i18n/locales/fr.ts | 10 + ui/src/i18n/locales/hi.ts | 9 + ui/src/i18n/locales/id.ts | 10 + ui/src/i18n/locales/it.ts | 10 + ui/src/i18n/locales/ja-JP.ts | 10 + ui/src/i18n/locales/ko.ts | 9 + ui/src/i18n/locales/nl.ts | 10 + ui/src/i18n/locales/pl.ts | 10 + ui/src/i18n/locales/pt-BR.ts | 10 + ui/src/i18n/locales/ru.ts | 10 + ui/src/i18n/locales/th.ts | 9 + ui/src/i18n/locales/tr.ts | 10 + ui/src/i18n/locales/uk.ts | 9 + ui/src/i18n/locales/vi.ts | 10 + ui/src/i18n/locales/zh-CN.ts | 9 + ui/src/i18n/locales/zh-TW.ts | 9 + ui/src/pages/usage/data-types.ts | 2 + ui/src/pages/usage/route.ts | 7 +- ui/src/pages/usage/types.ts | 2 + ui/src/pages/usage/usage-page.ts | 10 +- ui/src/pages/usage/view.test.ts | 46 ++++ ui/src/pages/usage/view.ts | 134 ++++++++++ ui/src/styles/usage.css | 122 +++++++++ 104 files changed, 2130 insertions(+), 164 deletions(-) create mode 100644 extensions/openrouter/usage.test.ts create mode 100644 extensions/openrouter/usage.ts create mode 100644 extensions/venice/usage.test.ts create mode 100644 extensions/venice/usage.ts create mode 100644 src/status/codex-synthetic-usage.test.ts diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index c52da15993cb..2a0e98d43ae7 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -8,7 +8,7 @@ title: "Usage tracking" ## What it is -- Pulls provider usage/quota directly from each provider's usage endpoint. No estimated costs; only provider-reported quota windows, balances, or account-state summaries. +- Pulls provider usage/quota directly from each provider's usage endpoint. No estimated provider billing; only provider-reported plan names, quota windows, balances, spend, budgets, or account-state summaries. - Human-readable quota-window output is normalized to `X% left`, even when a provider reports consumed quota, remaining quota, or only raw counts. Providers without resettable quota windows show provider summary text instead (for example a balance). - Session-level `/status` and the `session_status` tool fall back to the session's transcript log when the live session snapshot is missing token/model data. That fallback fills missing token/cache counters, can recover the active runtime model label, and prefers the larger prompt-oriented total when session metadata is missing or smaller (`totalTokensFresh !== true`, zero, or below the transcript-derived value). Nonzero live values always win over the fallback. @@ -19,6 +19,7 @@ title: "Usage tracking" - `/usage cost` in chats: local cost summary aggregated from OpenClaw session logs. - CLI: `openclaw status --usage` prints a full per-provider usage/quota breakdown. - CLI: `openclaw models status` lists OAuth/token auth profiles and shows a usage-window summary next to each provider that has one. +- Control UI: **Usage** shows provider plan and billing cards above OpenClaw's session-derived token and estimated-cost analysis. - macOS menu bar: a root "Usage" section appears below Context when provider usage snapshots are available. See [Menu bar](/platforms/mac/menu-bar). `openclaw channels list` no longer prints provider usage; it points users to `openclaw status` or `openclaw models list` instead. @@ -282,19 +283,25 @@ renders e.g. `claude-sonnet-4-6 🌗 🐌 | 📚 [⣿⣿⣿⣿⣧]272k`. ## Providers + credentials -Usage is hidden when no usable provider usage auth can be resolved. Providers -supply their own usage-fetch logic; when that is unavailable OpenClaw falls back -to matching OAuth/API-key credentials from auth profiles, environment variables, -or config. +Usage is hidden when no usable provider usage auth can be resolved. OpenClaw +automatically discovers enabled provider plugins that declare +`contracts.usageProviders` and implement both `resolveUsageAuth` and +`fetchUsageSnapshot`; there is no separate core provider allowlist. The static +contract keeps discovery scoped without importing every provider plugin. Each +plugin owns its upstream endpoint and response mapping. The +shared snapshot keeps plan names, quota windows, balances, spend, and budgets +provider-neutral for CLI, app, and Control UI consumers. - **Anthropic (Claude)**: OAuth tokens in auth profiles. If the OAuth token lacks `user:profile` scope, falls back to a `claude.ai` web session (`CLAUDE_AI_SESSION_KEY`, `CLAUDE_WEB_SESSION_KEY`, or a `sessionKey=` cookie in `CLAUDE_WEB_COOKIE`) when set. + Model-scoped limits and enabled extra-usage monthly spend/budgets are included + when Anthropic reports them. - **ClawRouter**: API key (`CLAWROUTER_API_KEY`). Shows a monthly budget window - when a budget is configured, otherwise a request/token/cost summary. + and typed USD budget when configured; otherwise shows aggregate spend and a + request/token/cost summary. - **DeepSeek**: API key via env/config/auth store (`DEEPSEEK_API_KEY`). - Shows the provider-reported account balance as text instead of a percent-left - quota window. + Shows each provider-reported currency balance. - **GitHub Copilot**: OAuth tokens in auth profiles. - **Gemini CLI**: OAuth tokens in auth profiles. - **MiniMax**: API key or MiniMax OAuth auth profile. OpenClaw treats @@ -314,7 +321,17 @@ or config. `window_hours` / `window_minutes` fields are absent, and includes the model name in the plan label. - **OpenAI (Codex/ChatGPT plan)**: OAuth tokens in auth profiles (`ChatGPT-Account-Id` - header sent when an account id is present). API-key-only OpenAI usage is not tracked. + header sent when an account id is present). Shows the ChatGPT plan, resettable + Codex windows, and a credit balance when reported. Credits remain provider + credits; OpenClaw does not label them as dollars. API-key-only OpenAI usage is + not tracked. +- **OpenRouter**: API key or OAuth-backed API key (`OPENROUTER_API_KEY` or an auth + profile). Combines the account credits endpoint with the key quota endpoint, + so account balance/spend, key budget, and daily/weekly/monthly usage appear + when the credential can access them. Either endpoint can enrich the snapshot + independently. +- **Venice**: API key via env/config/auth store (`VENICE_API_KEY`). Shows USD and + DIEM balances plus DIEM epoch allocation usage when reported. - **Xiaomi MiMo**: two separate usage surfaces. Pay-as-you-go uses an API key (`XIAOMI_API_KEY`); the Token Plan uses a separate key (`XIAOMI_TOKEN_PLAN_API_KEY`). Neither currently reports quota windows. diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 9ec6bd23e3e3..78809a1b85e2 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -549,6 +549,7 @@ Use `contracts` only for static capability ownership metadata that OpenClaw can "webContentExtractors": ["firecrawl"], "webFetchProviders": ["firecrawl"], "webSearchProviders": ["gemini"], + "usageProviders": ["acme-ai"], "migrationProviders": ["hermes"], "gatewayMethodDispatch": ["authenticated-request"], "tools": ["firecrawl_search", "firecrawl_scrape"] @@ -578,6 +579,7 @@ Each list is optional: | `webContentExtractors` | `string[]` | Web-page content-extraction provider ids this plugin owns. | | `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. | | `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. | +| `usageProviders` | `string[]` | Provider ids whose usage-auth and usage-snapshot hooks this plugin owns. | | `migrationProviders` | `string[]` | Import provider ids this plugin owns for `openclaw migrate`. | | `gatewayMethodDispatch` | `string[]` | Reserved entitlement for authenticated plugin HTTP routes that dispatch Gateway methods in-process. | | `tools` | `string[]` | Agent tool names this plugin owns. | @@ -590,6 +592,8 @@ Runtime `api.registerTool(...)` registrations must match `contracts.tools`. Tool Provider plugins that implement `resolveExternalAuthProfiles` should declare `contracts.externalAuthProviders`; undeclared external-auth hooks are ignored. +Provider plugins that implement both `resolveUsageAuth` and `fetchUsageSnapshot` should declare each auto-discovered provider id in `contracts.usageProviders`. Usage discovery reads this contract before loading runtime code, then verifies both hooks after loading only the declared owners. + General embedding providers should declare `contracts.embeddingProviders` for each adapter registered with `api.registerEmbeddingProvider(...)`. Use the general contract for reusable vector generation, including providers consumed by memory search. `contracts.memoryEmbeddingProviders` is deprecated memory-specific compatibility and remains only while existing providers migrate to the generic embedding provider seam. `contracts.gatewayMethodDispatch` currently accepts `"authenticated-request"`. It is an API hygiene gate for native plugin HTTP routes that intentionally dispatch Gateway control-plane methods in-process, not a sandbox against malicious native plugins. Use it only for tightly reviewed bundled/operator surfaces that already require Gateway HTTP auth. diff --git a/docs/plugins/sdk-provider-plugins.md b/docs/plugins/sdk-provider-plugins.md index d189d72f61e8..21f5182239d3 100644 --- a/docs/plugins/sdk-provider-plugins.md +++ b/docs/plugins/sdk-provider-plugins.md @@ -606,6 +606,24 @@ catalog, API-key auth, and dynamic model resolution. auth but has no usable usage token, and OpenClaw must skip generic API-key/OAuth fallback. Return `null` or `undefined` when the provider did not handle the request and OpenClaw should continue with generic fallback. + + Declare the provider id in `contracts.usageProviders`. When that manifest + contract and **both** hooks are present, OpenClaw automatically includes + the provider in usage collection without loading unrelated provider + plugins. No core allowlist update is required. + `fetchUsageSnapshot` returns the shared provider-neutral shape: + + - `plan`: provider-reported subscription or key label + - `windows`: resettable quota windows as used percentages + - `billing`: typed `balance`, `spend`, or `budget` entries; `unit` can be + an ISO currency or a provider unit such as `credits` + - `summary`: compact provider-specific context that does not fit those + structured fields + + Keep currency semantics exact. A provider credit is not USD unless the + upstream contract says so. A plugin that implements only + `fetchUsageSnapshot` remains available for explicit/synthetic callers but + is not auto-discovered, because OpenClaw cannot resolve its usage credential. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 3618a60f581a..a5a70501cc1d 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -160,6 +160,12 @@ The compact footer keeps connection status, **Settings**, **Docs**, and mobile p - Raw JSON editor "Reset to saved" preserves the raw-authored shape (formatting, comments, `$include` layout) instead of re-rendering a flattened snapshot, so external edits survive a reset when the snapshot can safely round-trip. - Structured SecretRef object values render read-only in form text inputs, to prevent accidental object-to-string corruption. + + + - Session-derived token and estimated-cost analysis stays separate from provider billing. + - Provider cards call `usage.status` and show live plan names, quota windows, balances, spend, and budgets reported by configured provider plugins. + - A provider usage failure does not block the session/cost dashboard; unavailable provider cards show their own error state. + - Debug: status/health/models snapshots, event log, and manual RPC calls (`status`, `health`, `models.list`). diff --git a/extensions/anthropic/openclaw.plugin.json b/extensions/anthropic/openclaw.plugin.json index 72a3dc016131..15bcbe957fde 100644 --- a/extensions/anthropic/openclaw.plugin.json +++ b/extensions/anthropic/openclaw.plugin.json @@ -243,7 +243,8 @@ } ], "contracts": { - "mediaUnderstandingProviders": ["anthropic"] + "mediaUnderstandingProviders": ["anthropic"], + "usageProviders": ["anthropic"] }, "mediaUnderstandingProviderMetadata": { "anthropic": { diff --git a/extensions/clawrouter/openclaw.plugin.json b/extensions/clawrouter/openclaw.plugin.json index 653b57a7cb06..971bcc698751 100644 --- a/extensions/clawrouter/openclaw.plugin.json +++ b/extensions/clawrouter/openclaw.plugin.json @@ -5,6 +5,9 @@ }, "enabledByDefault": true, "providers": ["clawrouter"], + "contracts": { + "usageProviders": ["clawrouter"] + }, "setup": { "providers": [ { diff --git a/extensions/clawrouter/usage.test.ts b/extensions/clawrouter/usage.test.ts index e3708b5998dd..9e275ffb67bd 100644 --- a/extensions/clawrouter/usage.test.ts +++ b/extensions/clawrouter/usage.test.ts @@ -40,6 +40,16 @@ describe("ClawRouter usage", () => { resetAt: Date.UTC(2026, 7, 1), }, ], + billing: [ + { + type: "budget", + used: 25, + limit: 100, + unit: "USD", + period: "month", + resetAt: Date.UTC(2026, 7, 1), + }, + ], summary: "12 requests · 34,567 tokens · $25.00 used", plan: "Managed monthly budget", }); @@ -69,6 +79,7 @@ describe("ClawRouter usage", () => { expect(snapshot.windows).toEqual([]); expect(snapshot.summary).toBe("0 requests · 0 tokens · $0.00 used"); expect(snapshot.plan).toBe("Unmetered proxy key"); + expect(snapshot.billing).toEqual([{ type: "spend", amount: 0, unit: "USD" }]); }); it("does not expose an upstream error body", async () => { diff --git a/extensions/clawrouter/usage.ts b/extensions/clawrouter/usage.ts index 0c78abd9c90b..7632e97200a3 100644 --- a/extensions/clawrouter/usage.ts +++ b/extensions/clawrouter/usage.ts @@ -98,18 +98,36 @@ export async function fetchClawRouterUsage(params: { const budget = payload.budget; const limitMicros = nonNegativeNumber(budget?.limitMicros); const spentMicros = nonNegativeNumber(budget?.spentMicros); + const costMicros = nonNegativeNumber(payload.usage?.summary?.actualCostMicros); + const resetAt = resolveMonthlyResetAt(budget?.windowKey); const windows = []; if (budget?.configured === true && limitMicros !== undefined && spentMicros !== undefined) { windows.push({ label: "Monthly budget", usedPercent: limitMicros === 0 ? 100 : Math.min(100, (spentMicros / limitMicros) * 100), - resetAt: resolveMonthlyResetAt(budget?.windowKey), + resetAt, }); } + const billing: ProviderUsageSnapshot["billing"] = + budget?.configured === true && limitMicros !== undefined && spentMicros !== undefined + ? [ + { + type: "budget", + used: spentMicros / 1_000_000, + limit: limitMicros / 1_000_000, + unit: "USD", + period: "month", + resetAt, + }, + ] + : costMicros !== undefined + ? [{ type: "spend", amount: costMicros / 1_000_000, unit: "USD" }] + : undefined; return { provider: "clawrouter" as ProviderUsageSnapshot["provider"], displayName: "ClawRouter", windows, + ...(billing ? { billing } : {}), summary: buildSummary(payload), plan: budget?.configured === true ? "Managed monthly budget" : "Unmetered proxy key", }; diff --git a/extensions/deepseek/openclaw.plugin.json b/extensions/deepseek/openclaw.plugin.json index aad2ea04dfe9..2f82b2b95a9e 100644 --- a/extensions/deepseek/openclaw.plugin.json +++ b/extensions/deepseek/openclaw.plugin.json @@ -7,6 +7,9 @@ "enabledByDefault": true, "providerCatalogEntry": "./provider-discovery.ts", "providers": ["deepseek"], + "contracts": { + "usageProviders": ["deepseek"] + }, "providerEndpoints": [ { "endpointClass": "deepseek-native", diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index 2876fd016e8b..57168fa35ac2 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -153,7 +153,8 @@ } }, "contracts": { - "memoryEmbeddingProviders": ["github-copilot"] + "memoryEmbeddingProviders": ["github-copilot"], + "usageProviders": ["github-copilot"] }, "setup": { "providers": [ diff --git a/extensions/google/openclaw.plugin.json b/extensions/google/openclaw.plugin.json index cca582f02ca2..3acc5fa553a0 100644 --- a/extensions/google/openclaw.plugin.json +++ b/extensions/google/openclaw.plugin.json @@ -674,7 +674,8 @@ "realtimeVoiceProviders": ["google"], "speechProviders": ["google"], "videoGenerationProviders": ["google"], - "webSearchProviders": ["gemini"] + "webSearchProviders": ["gemini"], + "usageProviders": ["google-gemini-cli"] }, "mediaUnderstandingProviderMetadata": { "google": { diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index aa02138bc512..b96f0b18ed32 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -84,7 +84,8 @@ "imageGenerationProviders": ["minimax", "minimax-portal"], "musicGenerationProviders": ["minimax", "minimax-portal"], "videoGenerationProviders": ["minimax", "minimax-portal"], - "webSearchProviders": ["minimax"] + "webSearchProviders": ["minimax"], + "usageProviders": ["minimax"] }, "configContracts": { "compatibilityRuntimePaths": ["tools.web.search.apiKey"] diff --git a/extensions/openai/openclaw.plugin.json b/extensions/openai/openclaw.plugin.json index 324eeba48da6..2e37033aaffe 100644 --- a/extensions/openai/openclaw.plugin.json +++ b/extensions/openai/openclaw.plugin.json @@ -326,7 +326,8 @@ "memoryEmbeddingProviders": ["openai"], "mediaUnderstandingProviders": ["openai"], "imageGenerationProviders": ["openai"], - "videoGenerationProviders": ["openai"] + "videoGenerationProviders": ["openai"], + "usageProviders": ["openai"] }, "imageGenerationProviderMetadata": { "openai": { diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 400632271787..55703128a709 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -156,6 +156,10 @@ describe("openrouter provider hooks", () => { }); expect(providers.map((provider) => provider.id)).toEqual(["openrouter"]); + expect(providers[0]).toMatchObject({ + resolveUsageAuth: expect.any(Function), + fetchUsageSnapshot: expect.any(Function), + }); expect(speechProviders.map((provider) => provider.id)).toEqual(["openrouter"]); expect(mediaProviders.map((provider) => provider.id)).toEqual(["openrouter"]); expect(imageProviders.map((provider) => provider.id)).toEqual(["openrouter"]); diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index 428821a7373b..5c881c76a774 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -34,6 +34,7 @@ import { resolveOpenRouterThinkingProfile, supportsOpenRouterXHighThinking, } from "./thinking-policy.js"; +import { fetchOpenRouterUsage } from "./usage.js"; import { buildOpenRouterVideoGenerationProvider, listOpenRouterVideoModelCatalog, @@ -358,6 +359,18 @@ export default definePluginEntry({ extraParamsForTransport: resolveOpenRouterExtraParamsForTransport, wrapStreamFn: wrapOpenRouterProviderStream, isCacheTtlEligible: (ctx) => isOpenRouterCacheTtlModel(ctx.modelId), + resolveUsageAuth: async (ctx) => { + const apiKey = ctx.resolveApiKeyFromConfigAndStore({ + envDirect: [ctx.env.OPENROUTER_API_KEY], + }); + return apiKey ? { token: apiKey } : null; + }, + fetchUsageSnapshot: async (ctx) => + await fetchOpenRouterUsage({ + token: ctx.token, + timeoutMs: ctx.timeoutMs, + fetchFn: ctx.fetchFn, + }), }); api.registerMediaUnderstandingProvider(openrouterMediaUnderstandingProvider); api.registerImageGenerationProvider(buildOpenRouterImageGenerationProvider()); diff --git a/extensions/openrouter/openclaw.plugin.json b/extensions/openrouter/openclaw.plugin.json index e6ac7a0844e5..a8558f11690e 100644 --- a/extensions/openrouter/openclaw.plugin.json +++ b/extensions/openrouter/openclaw.plugin.json @@ -77,7 +77,8 @@ "imageGenerationProviders": ["openrouter"], "musicGenerationProviders": ["openrouter"], "videoGenerationProviders": ["openrouter"], - "speechProviders": ["openrouter"] + "speechProviders": ["openrouter"], + "usageProviders": ["openrouter"] }, "mediaUnderstandingProviderMetadata": { "openrouter": { diff --git a/extensions/openrouter/usage.test.ts b/extensions/openrouter/usage.test.ts new file mode 100644 index 000000000000..9cdaa5090f11 --- /dev/null +++ b/extensions/openrouter/usage.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchOpenRouterUsage } from "./usage.js"; + +function requestUrl(input: string | URL | Request): string { + return typeof input === "string" ? input : input instanceof URL ? input.href : input.url; +} + +describe("OpenRouter usage", () => { + it("combines account credits with key quota and period spend", async () => { + const fetchFn = vi.fn(async (input: string | URL | Request) => { + const url = requestUrl(input); + if (url.endsWith("/credits")) { + return Response.json({ data: { total_credits: 100, total_usage: 35.5 } }); + } + return Response.json({ + data: { + label: "Production", + limit: 20, + limit_remaining: 15, + limit_reset: "monthly", + usage: 35, + usage_daily: 1.25, + usage_weekly: 3.5, + usage_monthly: 5, + }, + }); + }); + + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(snapshot).toEqual({ + provider: "openrouter", + displayName: "OpenRouter", + windows: [{ label: "Monthly key budget", usedPercent: 25 }], + billing: [ + { type: "balance", label: "Account balance", amount: 64.5, unit: "USD" }, + { type: "spend", label: "Account usage", amount: 35.5, unit: "USD" }, + { + type: "budget", + label: "API key budget", + used: 5, + limit: 20, + unit: "USD", + period: "monthly", + }, + ], + summary: "$1.25 today · $3.50 this week · $5.00 this month", + plan: "Production", + }); + expect(fetchFn).toHaveBeenCalledTimes(2); + }); + + it("derives recurring budget usage from remaining credits when the period counter is absent", async () => { + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: vi.fn(async (input: string | URL | Request) => + requestUrl(input).endsWith("/credits") + ? new Response(null, { status: 403 }) + : Response.json({ + data: { + limit: 50, + limit_remaining: 42, + limit_reset: "weekly", + usage: 200, + }, + }), + ) as unknown as typeof fetch, + }); + + expect(snapshot.windows).toEqual([{ label: "Weekly key budget", usedPercent: 16 }]); + expect(snapshot.billing).toEqual([ + { + type: "budget", + label: "API key budget", + used: 8, + limit: 50, + unit: "USD", + period: "weekly", + }, + ]); + }); + + it("preserves an exhausted zero-dollar key limit", async () => { + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: vi.fn(async (input: string | URL | Request) => + requestUrl(input).endsWith("/credits") + ? new Response(null, { status: 403 }) + : Response.json({ + data: { + limit: 0, + limit_remaining: 0, + limit_reset: "monthly", + usage_monthly: 0, + }, + }), + ) as unknown as typeof fetch, + }); + + expect(snapshot.windows).toEqual([{ label: "Monthly key budget", usedPercent: 100 }]); + expect(snapshot.billing).toEqual([ + { + type: "budget", + label: "API key budget", + used: 0, + limit: 0, + unit: "USD", + period: "monthly", + }, + ]); + }); + + it("uses remaining credits when BYOK spend counts toward a recurring limit", async () => { + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: vi.fn(async (input: string | URL | Request) => + requestUrl(input).endsWith("/credits") + ? new Response(null, { status: 403 }) + : Response.json({ + data: { + limit: 20, + limit_remaining: 14, + limit_reset: "monthly", + usage_monthly: 5, + byok_usage_monthly: 1, + include_byok_in_limit: true, + }, + }), + ) as unknown as typeof fetch, + }); + + expect(snapshot.windows).toEqual([{ label: "Monthly key budget", usedPercent: 30 }]); + expect(snapshot.billing).toEqual([ + { + type: "budget", + label: "API key budget", + used: 6, + limit: 20, + unit: "USD", + period: "monthly", + }, + ]); + }); + + it("keeps key usage when account credits are unavailable", async () => { + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: vi.fn(async (input: string | URL | Request) => + requestUrl(input).endsWith("/credits") + ? new Response(null, { status: 403 }) + : Response.json({ data: { usage: 2.5 } }), + ) as unknown as typeof fetch, + }); + + expect(snapshot.error).toBeUndefined(); + expect(snapshot.billing).toEqual([ + { type: "spend", label: "API key usage", amount: 2.5, unit: "USD" }, + ]); + }); + + it("preserves an overdrawn account balance", async () => { + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: vi.fn(async (input: string | URL | Request) => + requestUrl(input).endsWith("/credits") + ? Response.json({ data: { total_credits: 10, total_usage: 12.5 } }) + : new Response(null, { status: 403 }), + ) as unknown as typeof fetch, + }); + + expect(snapshot.billing).toEqual([ + { type: "balance", label: "Account balance", amount: -2.5, unit: "USD" }, + { type: "spend", label: "Account usage", amount: 12.5, unit: "USD" }, + ]); + }); + + it("keeps key usage when the credits request fails in transport", async () => { + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: vi.fn(async (input: string | URL | Request) => { + if (requestUrl(input).endsWith("/credits")) { + throw new Error("network down"); + } + return Response.json({ data: { usage: 2.5 } }); + }) as unknown as typeof fetch, + }); + + expect(snapshot.error).toBeUndefined(); + expect(snapshot.billing).toEqual([ + { type: "spend", label: "API key usage", amount: 2.5, unit: "USD" }, + ]); + }); + + it("keeps key usage when the credits response has a malformed root", async () => { + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: vi.fn(async (input: string | URL | Request) => + requestUrl(input).endsWith("/credits") + ? Response.json(null) + : Response.json({ data: { usage: 2.5 } }), + ) as unknown as typeof fetch, + }); + + expect(snapshot.error).toBeUndefined(); + expect(snapshot.billing).toEqual([ + { type: "spend", label: "API key usage", amount: 2.5, unit: "USD" }, + ]); + }); + + it("returns a bounded HTTP error when neither endpoint is available", async () => { + const snapshot = await fetchOpenRouterUsage({ + token: "router-key", + timeoutMs: 5000, + fetchFn: vi.fn( + async () => new Response("private", { status: 401 }), + ) as unknown as typeof fetch, + }); + + expect(snapshot.error).toBe("HTTP 401"); + expect(snapshot.windows).toEqual([]); + }); +}); diff --git a/extensions/openrouter/usage.ts b/extensions/openrouter/usage.ts new file mode 100644 index 000000000000..3485ae1bbb88 --- /dev/null +++ b/extensions/openrouter/usage.ts @@ -0,0 +1,219 @@ +import type { ProviderUsageSnapshot } from "openclaw/plugin-sdk/provider-usage"; +import { buildUsageHttpErrorSnapshot } from "openclaw/plugin-sdk/provider-usage"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; + +const OPENROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024; +const OPENROUTER_API_ROOT = "https://openrouter.ai/api/v1"; + +type OpenRouterCreditsData = { + total_credits?: unknown; + total_usage?: unknown; +}; + +type OpenRouterKeyData = { + label?: unknown; + limit?: unknown; + limit_remaining?: unknown; + limit_reset?: unknown; + usage?: unknown; + usage_daily?: unknown; + usage_weekly?: unknown; + usage_monthly?: unknown; + byok_usage_daily?: unknown; + byok_usage_weekly?: unknown; + byok_usage_monthly?: unknown; + include_byok_in_limit?: unknown; +}; + +type EndpointResult = + | { ok: true; data: Record } + | { ok: false; status: number } + | { ok: false; reason: "malformed" | "transport" }; + +type OpenRouterLimitReset = "daily" | "weekly" | "monthly"; + +function nonNegativeNumber(value: unknown): number | undefined { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() + ? Number(value) + : Number.NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function objectRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function resolveLimitReset(value: unknown): OpenRouterLimitReset | undefined { + return value === "daily" || value === "weekly" || value === "monthly" ? value : undefined; +} + +function resolveKeyBudget( + data: OpenRouterKeyData | undefined, +): { used: number; limit: number; period?: OpenRouterLimitReset } | undefined { + const limit = nonNegativeNumber(data?.limit); + if (limit === undefined) { + return undefined; + } + const period = resolveLimitReset(data?.limit_reset); + const periodUsage = + period === "daily" + ? nonNegativeNumber(data?.usage_daily) + : period === "weekly" + ? nonNegativeNumber(data?.usage_weekly) + : period === "monthly" + ? nonNegativeNumber(data?.usage_monthly) + : nonNegativeNumber(data?.usage); + const remaining = nonNegativeNumber(data?.limit_remaining); + // `limit_remaining` already incorporates BYOK usage when the key is configured to count it. + const used = remaining === undefined ? periodUsage : Math.max(0, limit - remaining); + return used === undefined ? undefined : { used, limit, ...(period ? { period } : {}) }; +} + +async function readJson(response: Response, timeoutMs: number): Promise { + const buffer = await readResponseWithLimit(response, OPENROUTER_USAGE_RESPONSE_MAX_BYTES, { + chunkTimeoutMs: timeoutMs, + onOverflow: ({ maxBytes }) => new Error(`OpenRouter usage response exceeds ${maxBytes} bytes`), + onIdleTimeout: ({ chunkTimeoutMs }) => + new Error(`OpenRouter usage response stalled for ${chunkTimeoutMs}ms`), + }); + return JSON.parse(new TextDecoder().decode(buffer)); +} + +async function fetchEndpoint(params: { + path: "credits" | "key"; + token: string; + timeoutMs: number; + fetchFn: typeof fetch; +}): Promise { + let response: Response; + try { + response = await params.fetchFn(`${OPENROUTER_API_ROOT}/${params.path}`, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.token}`, + }, + signal: AbortSignal.timeout(params.timeoutMs), + }); + } catch { + return { ok: false, reason: "transport" }; + } + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + return { ok: false, status: response.status }; + } + try { + const root = objectRecord(await readJson(response, params.timeoutMs)); + const data = objectRecord(root?.data); + return data ? { ok: true, data } : { ok: false, reason: "malformed" }; + } catch { + return { ok: false, reason: "malformed" }; + } +} + +export async function fetchOpenRouterUsage(params: { + token: string; + timeoutMs: number; + fetchFn: typeof fetch; +}): Promise { + const [creditsResult, keyResult] = await Promise.all([ + fetchEndpoint({ ...params, path: "credits" }), + fetchEndpoint({ ...params, path: "key" }), + ]); + if (!creditsResult.ok && !keyResult.ok) { + const status = + "status" in creditsResult + ? creditsResult.status + : "status" in keyResult + ? keyResult.status + : undefined; + if (status !== undefined) { + return buildUsageHttpErrorSnapshot({ provider: "openrouter", status }); + } + const transportFailed = [creditsResult, keyResult].some( + (result) => "reason" in result && result.reason === "transport", + ); + return { + provider: "openrouter", + displayName: "OpenRouter", + windows: [], + error: transportFailed ? "Usage unavailable" : "Malformed usage response", + }; + } + + const credits = creditsResult.ok ? (creditsResult.data as OpenRouterCreditsData) : undefined; + const key = keyResult.ok ? (keyResult.data as OpenRouterKeyData) : undefined; + const totalCredits = nonNegativeNumber(credits?.total_credits); + const totalUsage = nonNegativeNumber(credits?.total_usage); + const keyUsage = nonNegativeNumber(key?.usage); + const keyBudget = resolveKeyBudget(key); + const windows = []; + if (keyBudget) { + const periodLabel = keyBudget.period + ? `${keyBudget.period[0]?.toUpperCase()}${keyBudget.period.slice(1)} key budget` + : "API key budget"; + windows.push({ + label: periodLabel, + usedPercent: + keyBudget.limit === 0 ? 100 : Math.min(100, (keyBudget.used / keyBudget.limit) * 100), + }); + } + + const billing: NonNullable = []; + if (totalCredits !== undefined && totalUsage !== undefined) { + billing.push({ + type: "balance", + label: "Account balance", + amount: totalCredits - totalUsage, + unit: "USD", + }); + billing.push({ + type: "spend", + label: "Account usage", + amount: totalUsage, + unit: "USD", + }); + } + if (keyBudget) { + billing.push({ + type: "budget", + label: "API key budget", + used: keyBudget.used, + limit: keyBudget.limit, + unit: "USD", + ...(keyBudget.period ? { period: keyBudget.period } : {}), + }); + } else if (keyUsage !== undefined) { + billing.push({ + type: "spend", + label: "API key usage", + amount: keyUsage, + unit: "USD", + }); + } + + const keyLabel = typeof key?.label === "string" ? key.label.trim() : ""; + const periodUsage = [ + ["today", nonNegativeNumber(key?.usage_daily)], + ["this week", nonNegativeNumber(key?.usage_weekly)], + ["this month", nonNegativeNumber(key?.usage_monthly)], + ] as const; + const summary = periodUsage + .flatMap(([period, amount]) => + amount === undefined ? [] : [`$${amount.toFixed(2)} ${period}`], + ) + .join(" · "); + + return { + provider: "openrouter", + displayName: "OpenRouter", + windows, + ...(billing.length > 0 ? { billing } : {}), + ...(summary ? { summary } : {}), + ...(keyLabel ? { plan: keyLabel } : {}), + }; +} diff --git a/extensions/venice/index.test.ts b/extensions/venice/index.test.ts index 5840db642510..fb9a8bead38f 100644 --- a/extensions/venice/index.test.ts +++ b/extensions/venice/index.test.ts @@ -4,6 +4,15 @@ import { describe, expect, it } from "vitest"; import plugin from "./index.js"; describe("venice provider plugin", () => { + it("registers provider-owned usage hooks", async () => { + const provider = await registerSingleProviderPlugin(plugin); + + expect(provider).toMatchObject({ + resolveUsageAuth: expect.any(Function), + fetchUsageSnapshot: expect.any(Function), + }); + }); + it("applies the shared xAI compat patch to Grok-backed Venice models only", async () => { const provider = await registerSingleProviderPlugin(plugin); diff --git a/extensions/venice/index.ts b/extensions/venice/index.ts index 2c958c7bed9e..c59cdfc7eb17 100644 --- a/extensions/venice/index.ts +++ b/extensions/venice/index.ts @@ -8,6 +8,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer import { applyVeniceConfig, VENICE_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildVeniceProvider } from "./provider-catalog.js"; import { createVeniceDeepSeekV4Wrapper } from "./stream.js"; +import { fetchVeniceUsage } from "./usage.js"; const PROVIDER_ID = "venice"; const XAI_UNSUPPORTED_SCHEMA_KEYWORDS = [ @@ -67,5 +68,17 @@ export default defineSingleProviderPluginEntry({ normalizeResolvedModel: ({ modelId, model }) => isXaiBackedVeniceModel(modelId) ? applyXaiModelCompat(model) : undefined, wrapStreamFn: (ctx) => createVeniceDeepSeekV4Wrapper(ctx.streamFn, ctx.thinkingLevel), + resolveUsageAuth: async (ctx) => { + const apiKey = ctx.resolveApiKeyFromConfigAndStore({ + envDirect: [ctx.env.VENICE_API_KEY], + }); + return apiKey ? { token: apiKey } : null; + }, + fetchUsageSnapshot: async (ctx) => + await fetchVeniceUsage({ + token: ctx.token, + timeoutMs: ctx.timeoutMs, + fetchFn: ctx.fetchFn, + }), }, }); diff --git a/extensions/venice/openclaw.plugin.json b/extensions/venice/openclaw.plugin.json index c6367a77f870..e2f27ebb8fde 100644 --- a/extensions/venice/openclaw.plugin.json +++ b/extensions/venice/openclaw.plugin.json @@ -5,6 +5,9 @@ }, "enabledByDefault": true, "providers": ["venice"], + "contracts": { + "usageProviders": ["venice"] + }, "providerAuthChoices": [ { "provider": "venice", diff --git a/extensions/venice/usage.test.ts b/extensions/venice/usage.test.ts new file mode 100644 index 000000000000..0d75cd65da90 --- /dev/null +++ b/extensions/venice/usage.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchVeniceUsage } from "./usage.js"; + +describe("Venice usage", () => { + it("maps balances and DIEM epoch allocation", async () => { + const fetchFn = vi.fn(async () => + Response.json({ + canConsume: true, + consumptionCurrency: "diem", + balances: { diem: "75", usd: 8.5 }, + diemEpochAllocation: "100", + }), + ); + + const snapshot = await fetchVeniceUsage({ + token: "venice-key", + timeoutMs: 5000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(snapshot).toEqual({ + provider: "venice", + displayName: "Venice", + windows: [{ label: "DIEM epoch", usedPercent: 25 }], + billing: [ + { type: "balance", label: "DIEM balance", amount: 75, unit: "DIEM" }, + { type: "balance", label: "USD balance", amount: 8.5, unit: "USD" }, + { + type: "budget", + label: "DIEM epoch", + used: 25, + limit: 100, + unit: "DIEM", + period: "epoch", + }, + ], + plan: "DIEM billing", + }); + expect(fetchFn).toHaveBeenCalledWith( + "https://api.venice.ai/api/v1/billing/balance", + expect.objectContaining({ + headers: { Accept: "application/json", Authorization: "Bearer venice-key" }, + }), + ); + }); + + it("preserves unavailable status with balances", async () => { + const snapshot = await fetchVeniceUsage({ + token: "venice-key", + timeoutMs: 5000, + fetchFn: vi.fn(async () => + Response.json({ canConsume: false, balances: { usd: 0 } }), + ) as unknown as typeof fetch, + }); + + expect(snapshot.summary).toBe("API consumption unavailable"); + expect(snapshot.billing).toEqual([ + { type: "balance", label: "USD balance", amount: 0, unit: "USD" }, + ]); + }); + + it("returns HTTP status without exposing provider error bodies", async () => { + const snapshot = await fetchVeniceUsage({ + token: "venice-key", + timeoutMs: 5000, + fetchFn: vi.fn(async () => new Response("private", { status: 403 })) as unknown as typeof fetch, + }); + + expect(snapshot.error).toBe("HTTP 403"); + }); + + it("rejects a malformed JSON root without throwing", async () => { + const snapshot = await fetchVeniceUsage({ + token: "venice-key", + timeoutMs: 5000, + fetchFn: vi.fn(async () => Response.json(null)) as unknown as typeof fetch, + }); + + expect(snapshot.error).toBe("Malformed usage response"); + expect(snapshot.windows).toEqual([]); + }); + + it("returns a stable error for transport failures", async () => { + const snapshot = await fetchVeniceUsage({ + token: "venice-key", + timeoutMs: 5000, + fetchFn: vi.fn(async () => { + throw new Error("network down"); + }) as unknown as typeof fetch, + }); + + expect(snapshot.error).toBe("Usage unavailable"); + expect(snapshot.windows).toEqual([]); + }); +}); diff --git a/extensions/venice/usage.ts b/extensions/venice/usage.ts new file mode 100644 index 000000000000..462f5cccdece --- /dev/null +++ b/extensions/venice/usage.ts @@ -0,0 +1,128 @@ +import type { ProviderUsageSnapshot } from "openclaw/plugin-sdk/provider-usage"; +import { buildUsageHttpErrorSnapshot } from "openclaw/plugin-sdk/provider-usage"; +import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime"; + +const VENICE_BALANCE_URL = "https://api.venice.ai/api/v1/billing/balance"; +const VENICE_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024; + +type VeniceBalanceResponse = { + canConsume?: unknown; + consumptionCurrency?: unknown; + balances?: { + diem?: unknown; + usd?: unknown; + }; + diemEpochAllocation?: unknown; +}; + +function nonNegativeNumber(value: unknown): number | undefined { + const parsed = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() + ? Number(value) + : Number.NaN; + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function objectRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +async function readPayload(response: Response, timeoutMs: number): Promise { + const buffer = await readResponseWithLimit(response, VENICE_USAGE_RESPONSE_MAX_BYTES, { + chunkTimeoutMs: timeoutMs, + onOverflow: ({ maxBytes }) => new Error(`Venice usage response exceeds ${maxBytes} bytes`), + onIdleTimeout: ({ chunkTimeoutMs }) => + new Error(`Venice usage response stalled for ${chunkTimeoutMs}ms`), + }); + const data = objectRecord(JSON.parse(new TextDecoder().decode(buffer))); + if (!data) { + throw new Error("Venice usage response is not an object"); + } + return data as VeniceBalanceResponse; +} + +export async function fetchVeniceUsage(params: { + token: string; + timeoutMs: number; + fetchFn: typeof fetch; +}): Promise { + let response: Response; + try { + response = await params.fetchFn(VENICE_BALANCE_URL, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${params.token}`, + }, + signal: AbortSignal.timeout(params.timeoutMs), + }); + } catch { + return { + provider: "venice", + displayName: "Venice", + windows: [], + error: "Usage unavailable", + }; + } + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + return buildUsageHttpErrorSnapshot({ provider: "venice", status: response.status }); + } + + let data: VeniceBalanceResponse; + try { + data = await readPayload(response, params.timeoutMs); + } catch { + return { + provider: "venice", + displayName: "Venice", + windows: [], + error: "Malformed usage response", + }; + } + + const diem = nonNegativeNumber(data.balances?.diem); + const usd = nonNegativeNumber(data.balances?.usd); + const allocation = nonNegativeNumber(data.diemEpochAllocation); + const windows = []; + if (diem !== undefined && allocation !== undefined && allocation > 0) { + windows.push({ + label: "DIEM epoch", + usedPercent: Math.min(100, Math.max(0, ((allocation - diem) / allocation) * 100)), + }); + } + + const billing: NonNullable = []; + if (diem !== undefined) { + billing.push({ type: "balance", label: "DIEM balance", amount: diem, unit: "DIEM" }); + } + if (usd !== undefined) { + billing.push({ type: "balance", label: "USD balance", amount: usd, unit: "USD" }); + } + if (diem !== undefined && allocation !== undefined && allocation > 0) { + billing.push({ + type: "budget", + label: "DIEM epoch", + used: Math.max(0, allocation - diem), + limit: allocation, + unit: "DIEM", + period: "epoch", + }); + } + + const consumptionCurrency = + typeof data.consumptionCurrency === "string" + ? data.consumptionCurrency.trim().toUpperCase() + : ""; + return { + provider: "venice", + displayName: "Venice", + windows, + ...(billing.length > 0 ? { billing } : {}), + ...(consumptionCurrency ? { plan: `${consumptionCurrency} billing` } : {}), + ...(data.canConsume === false ? { summary: "API consumption unavailable" } : {}), + }; +} diff --git a/extensions/xiaomi/openclaw.plugin.json b/extensions/xiaomi/openclaw.plugin.json index 21e569dd7f74..21813c316129 100644 --- a/extensions/xiaomi/openclaw.plugin.json +++ b/extensions/xiaomi/openclaw.plugin.json @@ -128,7 +128,8 @@ } ], "contracts": { - "speechProviders": ["xiaomi"] + "speechProviders": ["xiaomi"], + "usageProviders": ["xiaomi", "xiaomi-token-plan"] }, "providerAuthEnvVars": { "xiaomi": ["XIAOMI_API_KEY"], diff --git a/extensions/zai/openclaw.plugin.json b/extensions/zai/openclaw.plugin.json index 7cb0930a57c3..ba5a1fd62d4d 100644 --- a/extensions/zai/openclaw.plugin.json +++ b/extensions/zai/openclaw.plugin.json @@ -328,7 +328,8 @@ } ], "contracts": { - "mediaUnderstandingProviders": ["zai"] + "mediaUnderstandingProviders": ["zai"], + "usageProviders": ["zai"] }, "mediaUnderstandingProviderMetadata": { "zai": { diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 799c4995e247..a60317c8fb05 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -192,7 +192,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10430, + 10431, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 77593d1eddcd..703fcda24a17 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -1572,6 +1572,45 @@ describe("buildStatusReply subagent summary", () => { expect(providerUsageCall[0]?.providers).toEqual(["deepseek"]); }); + it("shows typed billing-only snapshots in /status output", async () => { + providerUsageMock.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: Date.now(), + providers: [ + { + provider: "openrouter", + displayName: "OpenRouter", + windows: [], + billing: [{ type: "balance", label: "Account balance", amount: 12.5, unit: "USD" }], + }, + ], + }); + + const text = await buildStatusText({ + cfg: baseCfg, + sessionEntry: { + sessionId: "sess-status-openrouter-billing", + updatedAt: 0, + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "openrouter", + model: "openai/gpt-5.4", + contextTokens: 1_000_000, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "api-key", + activeModelAuthOverride: "api-key", + }); + + expect(normalizeTestText(text)).toContain("Usage: Account balance: $12.50"); + }); + it("uses the session-selected model provider for /status usage", async () => { const usageResetBase = Math.floor(Date.now() / 1000); providerUsageMock.loadProviderUsageSummary.mockImplementation( diff --git a/src/infra/provider-usage.fetch.claude.test.ts b/src/infra/provider-usage.fetch.claude.test.ts index 469aae0b2d1a..1758b890f092 100644 --- a/src/infra/provider-usage.fetch.claude.test.ts +++ b/src/infra/provider-usage.fetch.claude.test.ts @@ -105,6 +105,45 @@ describe("fetchClaudeUsage", () => { ]); }); + it("parses model-scoped limits and extra usage billing", async () => { + const reset = "2026-01-12T00:00:00Z"; + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + limits: [ + { + kind: "weekly_scoped", + percent: 27, + resets_at: reset, + is_active: true, + scope: { model: { id: "claude-fable", display_name: "Fable" } }, + }, + { + percent: 80, + is_active: false, + scope: { model: { display_name: "Inactive" } }, + }, + ], + extra_usage: { + is_enabled: true, + monthly_limit: 100000, + used_credits: 4132, + utilization: 4.132, + currency: "usd", + }, + }), + ); + + const result = await fetchClaudeUsage("token", 5000, mockFetch); + + expect(result.windows).toEqual([ + { label: "Fable", usedPercent: 27, resetAt: new Date(reset).getTime() }, + { label: "Extra usage", usedPercent: 4.132 }, + ]); + expect(result.billing).toEqual([ + { type: "budget", used: 41.32, limit: 1000, unit: "USD", period: "month" }, + ]); + }); + it("clamps oauth usage windows and prefers sonnet over opus when both exist", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(200, { diff --git a/src/infra/provider-usage.fetch.claude.ts b/src/infra/provider-usage.fetch.claude.ts index 31df98ef0b36..54cc7ea113d0 100644 --- a/src/infra/provider-usage.fetch.claude.ts +++ b/src/infra/provider-usage.fetch.claude.ts @@ -14,6 +14,19 @@ type ClaudeUsageResponse = { seven_day?: { utilization?: number; resets_at?: string }; seven_day_sonnet?: { utilization?: number }; seven_day_opus?: { utilization?: number }; + limits?: Array<{ + percent?: number; + resets_at?: string; + is_active?: boolean; + scope?: { model?: { id?: string; display_name?: string } }; + }>; + extra_usage?: { + is_enabled?: boolean; + monthly_limit?: number; + used_credits?: number; + utilization?: number; + currency?: string; + }; }; type ClaudeWebOrganizationsResponse = Array<{ @@ -48,6 +61,31 @@ function buildClaudeUsageWindows(data: ClaudeUsageResponse): UsageWindow[] { }); } + const knownLabels = new Set(windows.map((window) => window.label.toLowerCase())); + for (const limit of data.limits ?? []) { + if (limit.is_active === false || !Number.isFinite(limit.percent)) { + continue; + } + const model = limit.scope?.model; + const label = model?.display_name?.trim() || model?.id?.trim(); + if (!label || knownLabels.has(label.toLowerCase())) { + continue; + } + knownLabels.add(label.toLowerCase()); + windows.push({ + label, + usedPercent: clampPercent(limit.percent ?? 0), + resetAt: limit.resets_at ? new Date(limit.resets_at).getTime() : undefined, + }); + } + + if (data.extra_usage?.is_enabled && Number.isFinite(data.extra_usage.utilization)) { + windows.push({ + label: "Extra usage", + usedPercent: clampPercent(data.extra_usage.utilization ?? 0), + }); + } + return windows; } @@ -187,10 +225,32 @@ export async function fetchClaudeUsage( } const data = parsed.data as ClaudeUsageResponse; const windows = buildClaudeUsageWindows(data); + const extra = data.extra_usage; + const unit = extra?.currency?.trim().toUpperCase() || "USD"; + const billing = + extra?.is_enabled === true && + typeof extra.used_credits === "number" && + Number.isFinite(extra.used_credits) && + extra.used_credits >= 0 && + typeof extra.monthly_limit === "number" && + Number.isFinite(extra.monthly_limit) && + extra.monthly_limit >= 0 + ? [ + { + type: "budget" as const, + // Anthropic reports extra-usage currency in minor units. + used: extra.used_credits / 100, + limit: extra.monthly_limit / 100, + unit, + period: "month", + }, + ] + : undefined; return { provider: "anthropic", displayName: PROVIDER_LABELS.anthropic, windows, + ...(billing ? { billing } : {}), }; } diff --git a/src/infra/provider-usage.fetch.codex.test.ts b/src/infra/provider-usage.fetch.codex.test.ts index dd58c4154957..ef09ff789628 100644 --- a/src/infra/provider-usage.fetch.codex.test.ts +++ b/src/infra/provider-usage.fetch.codex.test.ts @@ -33,7 +33,7 @@ describe("fetchCodexUsage", () => { expect(result.windows).toHaveLength(0); }); - it("parses windows, reset times, and plan balance", async () => { + it("parses windows, reset times, plan, and credit balance", async () => { const mockFetch = createProviderUsageFetch(async (_url, init) => { const headers = (init?.headers as Record | undefined) ?? {}; expect(headers["ChatGPT-Account-Id"]).toBe("acct-1"); @@ -60,7 +60,8 @@ describe("fetchCodexUsage", () => { const result = await fetchCodexUsage("token", "acct-1", 5000, mockFetch); expect(result.provider).toBe("openai"); - expect(result.plan).toBe("Plus ($12.50)"); + expect(result.plan).toBe("Plus"); + expect(result.billing).toEqual([{ type: "balance", amount: 12.5, unit: "credits" }]); expect(result.windows).toEqual([ { label: "3h", usedPercent: 35.5, resetAt: 1_700_000_000_000 }, { label: "Day", usedPercent: 75, resetAt: 1_700_050_000_000 }, @@ -136,7 +137,7 @@ describe("fetchCodexUsage", () => { expect(result.windows).toEqual([{ label: "6h", usedPercent: 11, resetAt: undefined }]); }); - it("builds a balance-only plan when credits exist without a plan type", async () => { + it("keeps credits as a provider unit instead of assuming dollars", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(200, { credits: { balance: "7.5" }, @@ -144,11 +145,12 @@ describe("fetchCodexUsage", () => { ); const result = await fetchCodexUsage("token", undefined, 5000, mockFetch); - expect(result.plan).toBe("$7.50"); + expect(result.plan).toBeUndefined(); + expect(result.billing).toEqual([{ type: "balance", amount: 7.5, unit: "credits" }]); expect(result.windows).toStrictEqual([]); }); - it("falls back invalid credit strings to a zero balance", async () => { + it("omits invalid credit strings", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(200, { plan_type: "Plus", @@ -157,6 +159,7 @@ describe("fetchCodexUsage", () => { ); const result = await fetchCodexUsage("token", undefined, 5000, mockFetch); - expect(result.plan).toBe("Plus ($0.00)"); + expect(result.plan).toBe("Plus"); + expect(result.billing).toBeUndefined(); }); }); diff --git a/src/infra/provider-usage.fetch.codex.ts b/src/infra/provider-usage.fetch.codex.ts index 0a8876276a10..e41114451ed5 100644 --- a/src/infra/provider-usage.fetch.codex.ts +++ b/src/infra/provider-usage.fetch.codex.ts @@ -129,13 +129,16 @@ export async function fetchCodexUsage( }); } - let plan = data.plan_type; + const plan = data.plan_type; + let billing: ProviderUsageSnapshot["billing"]; if (data.credits?.balance !== undefined && data.credits.balance !== null) { const balance = typeof data.credits.balance === "number" ? data.credits.balance - : (parseStrictFiniteNumber(data.credits.balance) ?? 0); - plan = plan ? `${plan} ($${balance.toFixed(2)})` : `$${balance.toFixed(2)}`; + : parseStrictFiniteNumber(data.credits.balance); + if (balance !== undefined && balance >= 0) { + billing = [{ type: "balance", amount: balance, unit: "credits" }]; + } } return { @@ -143,5 +146,6 @@ export async function fetchCodexUsage( displayName: PROVIDER_LABELS.openai, windows, plan, + ...(billing ? { billing } : {}), }; } diff --git a/src/infra/provider-usage.fetch.deepseek.test.ts b/src/infra/provider-usage.fetch.deepseek.test.ts index 618619b9dba6..45de98abe161 100644 --- a/src/infra/provider-usage.fetch.deepseek.test.ts +++ b/src/infra/provider-usage.fetch.deepseek.test.ts @@ -35,6 +35,10 @@ describe("fetchDeepSeekUsage", () => { provider: "deepseek", displayName: "DeepSeek", windows: [], + billing: [ + { type: "balance", amount: 1.25, unit: "USD" }, + { type: "balance", amount: 42.5, unit: "CNY" }, + ], summary: "Balance $1.25 · Balance ¥42.50 · Granted ¥12.00 · Topped up ¥30.50", }); }); @@ -54,6 +58,7 @@ describe("fetchDeepSeekUsage", () => { const result = await fetchDeepSeekUsage("deepseek-key", 5000, mockFetch); expect(result.summary).toBe("Balance 3.00 EUR"); + expect(result.billing).toEqual([{ type: "balance", amount: 3, unit: "EUR" }]); }); it("returns HTTP errors for failed balance requests", async () => { diff --git a/src/infra/provider-usage.fetch.deepseek.ts b/src/infra/provider-usage.fetch.deepseek.ts index 87ade1f42fc5..f4e4ffd54188 100644 --- a/src/infra/provider-usage.fetch.deepseek.ts +++ b/src/infra/provider-usage.fetch.deepseek.ts @@ -92,6 +92,19 @@ export async function fetchDeepSeekUsage( .map((info) => buildBalanceSummary(info)) .filter((entry): entry is string => Boolean(entry)) .join(" · "); + const billing = balances.flatMap((info) => { + const amount = parseBalanceAmount(info.total_balance); + if (amount === undefined || amount < 0) { + return []; + } + return [ + { + type: "balance" as const, + amount, + unit: info.currency?.trim().toUpperCase() || "credits", + }, + ]; + }); if (!summary) { return { provider: "deepseek", @@ -105,6 +118,7 @@ export async function fetchDeepSeekUsage( provider: "deepseek", displayName: PROVIDER_LABELS.deepseek, windows: [], + billing, summary, ...(data.is_available === false ? { plan: "Unavailable" } : {}), }; diff --git a/src/infra/provider-usage.fetch.shared.ts b/src/infra/provider-usage.fetch.shared.ts index 2ddafc6d1e0f..dcc81cca0240 100644 --- a/src/infra/provider-usage.fetch.shared.ts +++ b/src/infra/provider-usage.fetch.shared.ts @@ -2,7 +2,7 @@ import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { readProviderJsonResponse } from "../agents/provider-http-errors.js"; import { parseFiniteNumber as parseFiniteNumberish } from "./parse-finite-number.js"; -import { PROVIDER_LABELS } from "./provider-usage.shared.js"; +import { resolveProviderUsageDisplayName } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.js"; /** Fetches JSON-compatible provider usage endpoints with an abort timeout. */ @@ -46,7 +46,7 @@ export function buildUsageErrorSnapshot( ): ProviderUsageSnapshot { return { provider, - displayName: PROVIDER_LABELS[provider], + displayName: resolveProviderUsageDisplayName(provider), windows: [], error, }; diff --git a/src/infra/provider-usage.format.test.ts b/src/infra/provider-usage.format.test.ts index c058d3d57d0f..f5cc6f32e180 100644 --- a/src/infra/provider-usage.format.test.ts +++ b/src/infra/provider-usage.format.test.ts @@ -115,6 +115,52 @@ describe("provider-usage.format", () => { ]); }); + it("formats typed balances and budgets across compact and detailed surfaces", () => { + const summary: UsageSummary = { + updatedAt: now, + providers: [ + { + provider: "openrouter", + displayName: "OpenRouter", + windows: [], + plan: "Production", + billing: [ + { type: "balance", label: "Account balance", amount: 64.5, unit: "USD" }, + { + type: "budget", + label: "API key budget", + used: 5, + limit: 20, + unit: "USD", + }, + ], + }, + ], + }; + + expect(formatUsageWindowSummary(summary.providers[0], { now })).toBe("Account balance: $64.50"); + expect(formatUsageSummaryLine(summary, { now })).toBe( + "📊 Usage: OpenRouter Account balance: $64.50", + ); + expect(formatUsageReportLines(summary, { now })).toEqual([ + "Usage:", + " OpenRouter (Production)", + " Account balance: $64.50", + " API key budget: $5.00 / $20.00", + ]); + }); + + it("places a negative currency sign before the symbol", () => { + expect( + formatUsageWindowSummary({ + provider: "openrouter", + displayName: "OpenRouter", + windows: [], + billing: [{ type: "balance", amount: -2.5, unit: "USD" }], + }), + ).toBe("Balance: -$2.50"); + }); + it("returns null summary line when providers are errored or have no windows", () => { expect( formatUsageSummaryLine({ diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index aac2a5784ef3..9ed166aa4cde 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -1,6 +1,11 @@ // Formats provider usage summaries for CLI and status output. import { clampPercent } from "./provider-usage.shared.js"; -import type { ProviderUsageSnapshot, UsageSummary, UsageWindow } from "./provider-usage.types.js"; +import type { + ProviderUsageBilling, + ProviderUsageSnapshot, + UsageSummary, + UsageWindow, +} from "./provider-usage.types.js"; // Compact reset times for chat/status lines; long windows fall back to a date. function formatResetRemaining(targetMs?: number, now?: number): string | null { @@ -42,6 +47,27 @@ function formatWindowShort(window: UsageWindow, now?: number): string { return `${remaining.toFixed(0)}% left (${window.label}${resetSuffix})`; } +function formatBillingAmount(amount: number, unit: string): string { + const normalizedUnit = unit.trim().toUpperCase(); + if (normalizedUnit === "USD") { + return amount < 0 ? `-$${Math.abs(amount).toFixed(2)}` : `$${amount.toFixed(2)}`; + } + if (normalizedUnit === "CNY" || normalizedUnit === "RMB") { + return amount < 0 ? `-¥${Math.abs(amount).toFixed(2)}` : `¥${amount.toFixed(2)}`; + } + return `${amount.toFixed(2)} ${unit}`; +} + +function formatBillingEntry(entry: ProviderUsageBilling): string { + const label = + entry.label ?? + (entry.type === "balance" ? "Balance" : entry.type === "spend" ? "Usage" : "Budget"); + if (entry.type === "budget") { + return `${label}: ${formatBillingAmount(entry.used, entry.unit)} / ${formatBillingAmount(entry.limit, entry.unit)}`; + } + return `${label}: ${formatBillingAmount(entry.amount, entry.unit)}`; +} + /** Formats one provider snapshot into a short usage-window summary. */ export function formatUsageWindowSummary( snapshot: ProviderUsageSnapshot, @@ -51,7 +77,8 @@ export function formatUsageWindowSummary( return null; } if (snapshot.windows.length === 0) { - return snapshot.summary?.trim() || null; + const billing = snapshot.billing?.[0]; + return snapshot.summary?.trim() || (billing ? formatBillingEntry(billing) : null); } const now = opts?.now ?? Date.now(); const maxWindows = @@ -74,7 +101,13 @@ export function formatUsageSummaryLine( opts?: { now?: number; maxProviders?: number }, ): string | null { const providers = summary.providers - .filter((entry) => (entry.windows.length > 0 || Boolean(entry.summary?.trim())) && !entry.error) + .filter( + (entry) => + (entry.windows.length > 0 || + Boolean(entry.summary?.trim()) || + Boolean(entry.billing?.length)) && + !entry.error, + ) .slice(0, opts?.maxProviders ?? summary.providers.length); if (providers.length === 0) { return null; @@ -84,6 +117,9 @@ export function formatUsageSummaryLine( if (entry.windows.length === 0 && entry.summary?.trim()) { return `${entry.displayName} ${entry.summary.trim()}`; } + if (entry.windows.length === 0 && entry.billing?.[0]) { + return `${entry.displayName} ${formatBillingEntry(entry.billing[0])}`; + } const window = entry.windows.reduce((best, next) => next.usedPercent > best.usedPercent ? next : best, ); @@ -104,7 +140,7 @@ export function formatUsageReportLines(summary: UsageSummary, opts?: { now?: num lines.push(` ${entry.displayName}${planSuffix}: ${entry.error}`); continue; } - if (entry.windows.length === 0) { + if (entry.windows.length === 0 && !entry.billing?.length) { lines.push(` ${entry.displayName}${planSuffix}: ${entry.summary?.trim() || "no data"}`); continue; } @@ -118,6 +154,9 @@ export function formatUsageReportLines(summary: UsageSummary, opts?: { now?: num const resetSuffix = reset ? ` · resets ${reset}` : ""; lines.push(` ${window.label}: ${remaining.toFixed(0)}% left${resetSuffix}`); } + for (const billing of entry.billing ?? []) { + lines.push(` ${formatBillingEntry(billing)}`); + } } return lines; } diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index 09ec363d32f6..8d3ebd76c0e9 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -1,14 +1,17 @@ // Loads provider usage snapshots from built-in and plugin providers. import { getRuntimeConfig, type OpenClawConfig } from "../config/config.js"; -import { resolveProviderUsageSnapshotWithPlugin } from "../plugins/provider-runtime.js"; +import { + listProviderUsagePluginDescriptors, + resolveProviderUsageSnapshotWithPlugin, + type ProviderUsagePluginDescriptor, +} from "../plugins/provider-runtime.js"; import { resolveFetch } from "./fetch.js"; import { resolveProxyFetchFromEnv } from "./net/proxy-fetch.js"; import { type ProviderAuth, resolveProviderAuths } from "./provider-usage.auth.js"; import { DEFAULT_TIMEOUT_MS, ignoredErrors, - PROVIDER_LABELS, - usageProviders, + resolveProviderUsageDisplayName, withTimeout, } from "./provider-usage.shared.js"; import type { @@ -27,7 +30,7 @@ async function fetchProviderUsageSnapshotFallback(params: { void params.fetchFn; return { provider: params.auth.provider, - displayName: PROVIDER_LABELS[params.auth.provider] ?? params.auth.provider, + displayName: resolveProviderUsageDisplayName(params.auth.provider), windows: [], error: "Unsupported provider", }; @@ -98,8 +101,26 @@ export async function loadProviderUsageSummary( throw new Error("fetch is not available"); } + const descriptors: ProviderUsagePluginDescriptor[] = opts.providers + ? opts.providers.map((provider) => ({ + provider, + displayName: resolveProviderUsageDisplayName(provider), + })) + : opts.auth + ? opts.auth.map((auth) => ({ + provider: auth.provider, + displayName: resolveProviderUsageDisplayName(auth.provider), + })) + : listProviderUsagePluginDescriptors({ + config, + workspaceDir: opts.workspaceDir, + env, + }); + const displayNames = new Map( + descriptors.map((descriptor) => [descriptor.provider, descriptor.displayName]), + ); const auths = await resolveProviderAuths({ - providers: opts.providers ?? usageProviders, + providers: descriptors.map((descriptor) => descriptor.provider), auth: opts.auth, agentDir: opts.agentDir, config, @@ -113,7 +134,8 @@ export async function loadProviderUsageSummary( const tasks = auths.map((auth) => { const failureSnapshot = (error: string): ProviderUsageSnapshot => ({ provider: auth.provider, - displayName: PROVIDER_LABELS[auth.provider] ?? auth.provider, + displayName: + displayNames.get(auth.provider) ?? resolveProviderUsageDisplayName(auth.provider), windows: [], error, }); @@ -130,7 +152,8 @@ export async function loadProviderUsageSummary( timeoutMs + 1000, { provider: auth.provider, - displayName: PROVIDER_LABELS[auth.provider], + displayName: + displayNames.get(auth.provider) ?? resolveProviderUsageDisplayName(auth.provider), windows: [], error: "Timeout", }, @@ -145,6 +168,9 @@ export async function loadProviderUsageSummary( if (entry.windows.length > 0) { return true; } + if (entry.billing && entry.billing.length > 0) { + return true; + } if (entry.summary?.trim()) { return true; } diff --git a/src/infra/provider-usage.shared.test.ts b/src/infra/provider-usage.shared.test.ts index 419cf747524c..7573e6e26072 100644 --- a/src/infra/provider-usage.shared.test.ts +++ b/src/infra/provider-usage.shared.test.ts @@ -13,13 +13,13 @@ describe("provider-usage.shared", () => { { value: "clawrouter", expected: "clawrouter" }, { value: "deepseek", expected: "deepseek" }, { value: "zai", expected: "zai" }, - { value: "z-ai", expected: undefined }, + { value: "z-ai", expected: "z-ai" }, { value: " GOOGLE-GEMINI-CLI ", expected: "google-gemini-cli" }, { value: "minimax-portal", expected: "minimax" }, { value: "minimax-cn", expected: "minimax" }, { value: "minimax-portal-cn", expected: "minimax" }, { value: " XIAOMI-TOKEN-PLAN ", expected: "xiaomi-token-plan" }, - { value: "unknown-provider", expected: undefined }, + { value: "unknown-provider", expected: "unknown-provider" }, { value: undefined, expected: undefined }, { value: null, expected: undefined }, ])("normalizes provider ids for %j", ({ value, expected }) => { diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index 67fd00f6212f..a3d23a92f255 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -6,7 +6,7 @@ import type { UsageProviderId } from "./provider-usage.types.js"; /** Default timeout for provider usage collection. */ export const DEFAULT_TIMEOUT_MS = 5000; -export const PROVIDER_LABELS: Record = { +export const PROVIDER_LABELS: Readonly> = { anthropic: "Claude", clawrouter: "ClawRouter", deepseek: "DeepSeek", @@ -14,30 +14,23 @@ export const PROVIDER_LABELS: Record = { "google-gemini-cli": "Gemini", minimax: "MiniMax", openai: "OpenAI", + openrouter: "OpenRouter", + venice: "Venice", xiaomi: "Xiaomi", "xiaomi-token-plan": "Xiaomi Token Plan", zai: "z.ai", }; -export const usageProviders: UsageProviderId[] = [ - "anthropic", - "clawrouter", - "deepseek", - "github-copilot", - "google-gemini-cli", - "minimax", - "openai", - "xiaomi", - "xiaomi-token-plan", - "zai", -]; +export function resolveProviderUsageDisplayName(provider: string): string { + return PROVIDER_LABELS[provider] ?? provider; +} /** Returns true for providers whose usage endpoint is only meaningful with OAuth/token auth. */ export function isOAuthOnlyUsageProvider(provider: UsageProviderId): boolean { return provider === "openai"; } -/** Maps model/provider ids and credential type into supported usage provider ids. */ +/** Maps model/provider ids and credential type into a normalized usage provider id. */ export function resolveUsageProviderId( provider?: string | null, options?: { credentialType?: string | null }, @@ -62,9 +55,7 @@ export function resolveUsageProviderId( ) { return "minimax"; } - return usageProviders.includes(normalized as UsageProviderId) - ? (normalized as UsageProviderId) - : undefined; + return normalized || undefined; } export const ignoredErrors = new Set([ diff --git a/src/infra/provider-usage.ts b/src/infra/provider-usage.ts index 824ba0742f42..95dcc22bb477 100644 --- a/src/infra/provider-usage.ts +++ b/src/infra/provider-usage.ts @@ -7,6 +7,7 @@ export { export { loadProviderUsageSummary } from "./provider-usage.load.js"; export { resolveUsageProviderId } from "./provider-usage.shared.js"; export type { + ProviderUsageBilling, ProviderUsageSnapshot, UsageProviderId, UsageSummary, diff --git a/src/infra/provider-usage.types.ts b/src/infra/provider-usage.types.ts index 318c294927f8..2d2dc5bd4022 100644 --- a/src/infra/provider-usage.types.ts +++ b/src/infra/provider-usage.types.ts @@ -5,10 +5,37 @@ export type UsageWindow = { resetAt?: number; }; +/** Provider-reported monetary or credit facts. Units may be ISO currencies or provider credits. */ +export type ProviderUsageBilling = + | { + type: "balance"; + label?: string; + amount: number; + unit: string; + } + | { + type: "spend"; + label?: string; + amount: number; + unit: string; + period?: string; + resetAt?: number; + } + | { + type: "budget"; + label?: string; + used: number; + limit: number; + unit: string; + period?: string; + resetAt?: number; + }; + export type ProviderUsageSnapshot = { provider: UsageProviderId; displayName: string; windows: UsageWindow[]; + billing?: ProviderUsageBilling[]; summary?: string; plan?: string; error?: string; @@ -19,14 +46,5 @@ export type UsageSummary = { providers: ProviderUsageSnapshot[]; }; -export type UsageProviderId = - | "anthropic" - | "clawrouter" - | "deepseek" - | "github-copilot" - | "google-gemini-cli" - | "minimax" - | "openai" - | "xiaomi" - | "xiaomi-token-plan" - | "zai"; +/** Normalized provider id. Usage providers are discovered from plugin hooks at runtime. */ +export type UsageProviderId = string; diff --git a/src/plugin-sdk/provider-usage.ts b/src/plugin-sdk/provider-usage.ts index ba0cc966c02c..b930e774bdf5 100644 --- a/src/plugin-sdk/provider-usage.ts +++ b/src/plugin-sdk/provider-usage.ts @@ -1,6 +1,7 @@ // Public usage fetch helpers for provider plugins. export type { + ProviderUsageBilling, ProviderUsageSnapshot, UsageProviderId, UsageWindow, diff --git a/src/plugins/installed-plugin-index-scope-lookup.ts b/src/plugins/installed-plugin-index-scope-lookup.ts index b01b13e49ec1..0643c7f18ecc 100644 --- a/src/plugins/installed-plugin-index-scope-lookup.ts +++ b/src/plugins/installed-plugin-index-scope-lookup.ts @@ -19,6 +19,7 @@ const PROVIDER_CONTRIBUTION_CONTRACTS = [ "musicGenerationProviders", "webFetchProviders", "webSearchProviders", + "usageProviders", ] as const; type OwnerMap = ReadonlyMap; diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 463765c0c1f5..da7de3450245 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -2024,13 +2024,14 @@ describe("loadPluginManifestRegistry", () => { }); }); - it("preserves external auth provider contracts from plugin manifests", () => { + it("preserves provider hook contracts from plugin manifests", () => { const dir = makeTempDir(); writeManifest(dir, { id: "acme-ai", providers: ["acme-ai"], contracts: { externalAuthProviders: ["acme-ai"], + usageProviders: ["acme-ai"], }, configSchema: { type: "object" }, }); @@ -2043,6 +2044,7 @@ describe("loadPluginManifestRegistry", () => { expect(registry.plugins[0]?.contracts).toEqual({ externalAuthProviders: ["acme-ai"], + usageProviders: ["acme-ai"], }); }); @@ -2073,16 +2075,19 @@ describe("loadPluginManifestRegistry", () => { const contracts = manifestRegistryTesting.mergeManifestContracts( { agentToolResultMiddleware: ["openclaw"], + usageProviders: ["openai"], }, { agentToolResultMiddleware: ["codex"], trustedToolPolicies: ["workflow-budget"], + usageProviders: ["openrouter"], }, ); expect(contracts).toEqual({ agentToolResultMiddleware: ["openclaw", "codex"], trustedToolPolicies: ["workflow-budget"], + usageProviders: ["openai", "openrouter"], }); }); diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 72cb4e3471a4..243137731a22 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -180,6 +180,7 @@ export type PluginManifestContractListKey = | "webContentExtractors" | "webFetchProviders" | "webSearchProviders" + | "usageProviders" | "migrationProviders" | "gatewayMethodDispatch"; @@ -378,6 +379,7 @@ function mergeManifestContracts( "webContentExtractors", "webFetchProviders", "webSearchProviders", + "usageProviders", "migrationProviders", "gatewayMethodDispatch", "tools", diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index e86707c3a3bf..6ba8ae92acdd 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -427,6 +427,8 @@ export type PluginManifestContracts = { webContentExtractors?: string[]; webFetchProviders?: string[]; webSearchProviders?: string[]; + /** Provider ids whose plugin owns usage auth and snapshot hooks. */ + usageProviders?: string[]; migrationProviders?: string[]; gatewayMethodDispatch?: string[]; tools?: string[]; @@ -866,6 +868,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u const webContentExtractors = normalizeTrimmedStringList(value.webContentExtractors); const webFetchProviders = normalizeTrimmedStringList(value.webFetchProviders); const webSearchProviders = normalizeTrimmedStringList(value.webSearchProviders); + const usageProviders = normalizeTrimmedStringList(value.usageProviders); const migrationProviders = normalizeTrimmedStringList(value.migrationProviders); const gatewayMethodDispatch = normalizeTrimmedStringList(value.gatewayMethodDispatch); const tools = normalizeTrimmedStringList(value.tools); @@ -888,6 +891,7 @@ function normalizeManifestContracts(value: unknown): PluginManifestContracts | u ...(webContentExtractors.length > 0 ? { webContentExtractors } : {}), ...(webFetchProviders.length > 0 ? { webFetchProviders } : {}), ...(webSearchProviders.length > 0 ? { webSearchProviders } : {}), + ...(usageProviders.length > 0 ? { usageProviders } : {}), ...(migrationProviders.length > 0 ? { migrationProviders } : {}), ...(gatewayMethodDispatch.length > 0 ? { gatewayMethodDispatch } : {}), ...(tools.length > 0 ? { tools } : {}), diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index 5e383b5d6145..32b74a007363 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -23,6 +23,8 @@ type IsPluginProvidersLoadInFlight = typeof import("./providers.runtime.js").isPluginProvidersLoadInFlight; type ResolveCatalogHookProviderPluginIds = typeof import("./providers.js").resolveCatalogHookProviderPluginIds; +type ResolveUsageHookProviderPluginContracts = + typeof import("./providers.js").resolveUsageHookProviderPluginContracts; type ResolveExternalAuthProfileCompatFallbackPluginIds = typeof import("./providers.js").resolveExternalAuthProfileCompatFallbackPluginIds; type ResolveExternalAuthProfileProviderPluginIds = @@ -37,6 +39,9 @@ const isPluginProvidersLoadInFlightMock = vi.fn(( const resolveCatalogHookProviderPluginIdsMock = vi.fn( (_) => [] as string[], ); +const resolveUsageHookProviderPluginContractsMock = vi.fn( + (_) => [], +); const resolveExternalAuthProfileCompatFallbackPluginIdsMock = vi.fn((_) => [] as string[]); const resolveExternalAuthProfileProviderPluginIdsMock = @@ -87,6 +92,7 @@ let resolveProviderUsageAuthWithPlugin: typeof import("./provider-runtime.js").r let resolveProviderXHighThinking: typeof import("./provider-runtime.js").resolveProviderXHighThinking; let normalizeProviderToolSchemasWithPlugin: typeof import("./provider-runtime.js").normalizeProviderToolSchemasWithPlugin; let inspectProviderToolSchemasWithPlugin: typeof import("./provider-runtime.js").inspectProviderToolSchemasWithPlugin; +let listProviderUsagePluginDescriptors: typeof import("./provider-runtime.js").listProviderUsagePluginDescriptors; let normalizeProviderResolvedModelWithPlugin: typeof import("./provider-runtime.js").normalizeProviderResolvedModelWithPlugin; let prepareProviderDynamicModel: typeof import("./provider-runtime.js").prepareProviderDynamicModel; let prepareProviderRuntimeAuth: typeof import("./provider-runtime.js").prepareProviderRuntimeAuth; @@ -291,6 +297,8 @@ describe("provider-runtime", () => { vi.doMock("./providers.js", () => ({ resolveCatalogHookProviderPluginIds: (params: unknown) => resolveCatalogHookProviderPluginIdsMock(params as never), + resolveUsageHookProviderPluginContracts: (params: unknown) => + resolveUsageHookProviderPluginContractsMock(params as never), resolveExternalAuthProfileCompatFallbackPluginIds: (params: unknown) => resolveExternalAuthProfileCompatFallbackPluginIdsMock(params as never), resolveExternalAuthProfileProviderPluginIds: (params: unknown) => @@ -352,6 +360,7 @@ describe("provider-runtime", () => { resolveProviderXHighThinking, normalizeProviderToolSchemasWithPlugin, inspectProviderToolSchemasWithPlugin, + listProviderUsagePluginDescriptors, normalizeProviderResolvedModelWithPlugin, prepareProviderDynamicModel, prepareProviderRuntimeAuth, @@ -377,6 +386,8 @@ describe("provider-runtime", () => { isPluginProvidersLoadInFlightMock.mockReturnValue(false); resolveCatalogHookProviderPluginIdsMock.mockReset(); resolveCatalogHookProviderPluginIdsMock.mockReturnValue([]); + resolveUsageHookProviderPluginContractsMock.mockReset(); + resolveUsageHookProviderPluginContractsMock.mockReturnValue([]); resolveExternalAuthProfileCompatFallbackPluginIdsMock.mockReset(); resolveExternalAuthProfileCompatFallbackPluginIdsMock.mockReturnValue([]); resolveExternalAuthProfileProviderPluginIdsMock.mockReset(); @@ -404,6 +415,32 @@ describe("provider-runtime", () => { }); }); + it("auto-discovers only usage providers declared by their owning plugin", () => { + resolveUsageHookProviderPluginContractsMock.mockReturnValue([ + { pluginId: "multi-provider", providerIds: ["declared"] }, + ]); + const usageHooks = { + auth: [], + resolveUsageAuth: vi.fn(async () => ({ token: "usage-token" })), + fetchUsageSnapshot: vi.fn(async () => ({ + provider: "declared", + displayName: "Declared", + windows: [], + })), + }; + resolvePluginProvidersMock.mockReturnValue([ + { ...usageHooks, id: "declared", label: "Declared" }, + { ...usageHooks, id: "undeclared", label: "Undeclared" }, + ]); + + expect(listProviderUsagePluginDescriptors({ env: process.env })).toEqual([ + { provider: "declared", displayName: "Declared" }, + ]); + expect(resolvePluginProvidersMock).toHaveBeenCalledWith( + expect.objectContaining({ onlyPluginIds: ["multi-provider"] }), + ); + }); + it("matches providers by hook alias for runtime hook lookup", () => { resolvePluginProvidersMock.mockReturnValue([ { @@ -1850,6 +1887,9 @@ describe("provider-runtime", () => { it("dispatches runtime hooks for the matched provider", async () => { resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["openai"]); + resolveUsageHookProviderPluginContractsMock.mockReturnValue([ + { pluginId: "demo", providerIds: ["demo"] }, + ]); resolveExternalAuthProfileProviderPluginIdsMock.mockReturnValue(["demo"]); const prepareDynamicModel = vi.fn(async () => undefined); const createStreamFn = vi.fn(() => vi.fn()); @@ -1996,6 +2036,10 @@ describe("provider-runtime", () => { ]; }); + expect(listProviderUsagePluginDescriptors({ env: process.env })).toEqual([ + { provider: "demo", displayName: "Demo" }, + ]); + expect( runProviderDynamicModel({ provider: DEMO_PROVIDER_ID, diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index e85c361a2b0d..a20c58afb77b 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -18,6 +18,7 @@ import { import type { ProviderSystemPromptContribution } from "../agents/system-prompt-contribution.js"; import type { ModelProviderConfig } from "../config/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { UsageProviderId } from "../infra/provider-usage.types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; @@ -48,6 +49,7 @@ import { resolveExternalAuthProfileProviderPluginIds, resolveOwningPluginIdsForProvider, resolveOwningPluginIdsForProviderRef, + resolveUsageHookProviderPluginContracts, } from "./providers.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js"; @@ -653,6 +655,44 @@ export async function resolveProviderUsageSnapshotWithPlugin(params: { return await resolveProviderRuntimePlugin(params)?.fetchUsageSnapshot?.(params.context); } +export type ProviderUsagePluginDescriptor = { + provider: UsageProviderId; + displayName: string; +}; + +/** Lists provider plugins that own the complete usage auth + fetch lifecycle. */ +export function listProviderUsagePluginDescriptors(params: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ProviderUsagePluginDescriptor[] { + const pluginContracts = resolveUsageHookProviderPluginContracts(params); + if (pluginContracts.length === 0) { + return []; + } + const descriptors = new Map(); + for (const contract of pluginContracts) { + const declaredProviderIds = new Set(contract.providerIds); + for (const plugin of resolveProviderPluginsForHooks({ + ...params, + onlyPluginIds: [contract.pluginId], + })) { + if (!plugin.resolveUsageAuth || !plugin.fetchUsageSnapshot) { + continue; + } + const provider = normalizeProviderId(plugin.id); + if (!provider || !declaredProviderIds.has(provider) || descriptors.has(provider)) { + continue; + } + descriptors.set(provider, { + provider, + displayName: normalizeOptionalString(plugin.label) ?? provider, + }); + } + } + return [...descriptors.values()].toSorted((a, b) => a.provider.localeCompare(b.provider)); +} + export function matchesProviderContextOverflowWithPlugin(params: { provider?: string; config?: OpenClawConfig; diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 5b00dbbc902b..a3237eaff424 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -41,6 +41,7 @@ let resolveProviderRefOwnership: typeof import("./providers.js").resolveProvider let resolveActivatableProviderOwnerPluginIds: typeof import("./providers.js").resolveActivatableProviderOwnerPluginIds; let resolveEnabledProviderPluginIds: typeof import("./providers.js").resolveEnabledProviderPluginIds; let resolveCatalogHookProviderPluginIds: typeof import("./providers.js").resolveCatalogHookProviderPluginIds; +let resolveUsageHookProviderPluginContracts: typeof import("./providers.js").resolveUsageHookProviderPluginContracts; let resolveExternalAuthProfileCompatFallbackPluginIds: typeof import("./providers.js").resolveExternalAuthProfileCompatFallbackPluginIds; let resolveExternalAuthProfileProviderPluginIds: typeof import("./providers.js").resolveExternalAuthProfileProviderPluginIds; let resolveDiscoveredProviderPluginIds: typeof import("./providers.js").resolveDiscoveredProviderPluginIds; @@ -524,6 +525,7 @@ describe("resolvePluginProviders", () => { resolveProviderRefOwnership, resolveEnabledProviderPluginIds, resolveCatalogHookProviderPluginIds, + resolveUsageHookProviderPluginContracts, resolveExternalAuthProfileCompatFallbackPluginIds, resolveExternalAuthProfileProviderPluginIds, resolveDiscoveredProviderPluginIds, @@ -898,6 +900,27 @@ describe("resolvePluginProviders", () => { ).toEqual(["ollama"]); }); + it("loads usage hooks only for manifest-declared providers", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "usage-owner", + providerIds: ["usage-provider"], + enabledByDefault: true, + contracts: { usageProviders: ["usage-provider"] }, + }), + createManifestProviderPlugin({ + id: "regular-provider", + providerIds: ["regular-provider"], + enabledByDefault: true, + }), + ]); + + expect( + resolveUsageHookProviderPluginContracts({ config: {}, env: {} as NodeJS.ProcessEnv }), + ).toEqual([{ pluginId: "usage-owner", providerIds: ["usage-provider"] }]); + expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); + }); + it("resolves external auth hook plugin ids from manifest contracts without runtime loading", () => { setManifestPlugins([ createManifestProviderPlugin({ diff --git a/src/plugins/providers.ts b/src/plugins/providers.ts index f28c259bff24..e8a7e26e9ec4 100644 --- a/src/plugins/providers.ts +++ b/src/plugins/providers.ts @@ -852,4 +852,52 @@ export function resolveCatalogHookProviderPluginIds(params: { }).filter((pluginId) => runtimeAugmentPluginIds.has(pluginId)); return sortUniqueStrings([...enabledProviderPluginIds, ...bundledCompatPluginIds]); } + +export type UsageHookProviderPluginContract = { + pluginId: string; + providerIds: string[]; +}; + +export function resolveUsageHookProviderPluginContracts(params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; +}): UsageHookProviderPluginContract[] { + const registry = loadProviderRegistrySnapshot(params); + const manifestRegistry = resolveManifestRegistry({ + ...params, + registry, + includeDisabled: true, + }); + const usagePluginIds = new Set( + manifestRegistry.plugins.flatMap((plugin) => + plugin.contracts?.usageProviders?.length ? [plugin.id] : [], + ), + ); + const normalizedConfig = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); + const enabledPluginIds = listRegistryPluginIds( + registry, + (plugin) => + usagePluginIds.has(plugin.pluginId) && + resolveEffectiveRegistryPluginActivation({ + plugin, + normalizedConfig, + rootConfig: params.config, + }).activated, + ); + const bundledCompatPluginIds = resolveBundledProviderCompatPluginIds({ + ...params, + manifestRegistry, + }).filter((pluginId) => usagePluginIds.has(pluginId)); + const pluginIds = sortUniqueStrings([...enabledPluginIds, ...bundledCompatPluginIds]); + const manifestsById = new Map(manifestRegistry.plugins.map((plugin) => [plugin.id, plugin])); + return pluginIds.flatMap((pluginId) => { + const providerIds = sortUniqueStrings( + (manifestsById.get(pluginId)?.contracts?.usageProviders ?? []) + .map(normalizeProviderId) + .filter(Boolean), + ); + return providerIds.length > 0 ? [{ pluginId, providerIds }] : []; + }); +} export { testing as __testing }; diff --git a/src/status/codex-synthetic-usage.test.ts b/src/status/codex-synthetic-usage.test.ts new file mode 100644 index 000000000000..e7a46e887494 --- /dev/null +++ b/src/status/codex-synthetic-usage.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { mergeUsageSummaries } from "./codex-synthetic-usage.js"; + +describe("mergeUsageSummaries", () => { + it("preserves OAuth plan and billing when synthetic Codex windows win", () => { + const merged = mergeUsageSummaries( + { + updatedAt: 1, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + plan: "Plus", + windows: [{ label: "Week", usedPercent: 40 }], + billing: [{ type: "balance", amount: 12.5, unit: "credits" }], + }, + ], + }, + { + updatedAt: 2, + providers: [ + { + provider: "openai", + displayName: "Codex", + windows: [{ label: "5h", usedPercent: 10 }], + }, + ], + }, + ); + + expect(merged).toEqual({ + updatedAt: 1, + providers: [ + { + provider: "openai", + displayName: "Codex", + plan: "Plus", + windows: [{ label: "5h", usedPercent: 10 }], + billing: [{ type: "balance", amount: 12.5, unit: "credits" }], + error: undefined, + }, + ], + }); + }); + + it("ranks billing-only snapshots above errors", () => { + const merged = mergeUsageSummaries( + { + updatedAt: 1, + providers: [ + { + provider: "openai", + displayName: "OpenAI", + windows: [], + billing: [{ type: "balance", amount: 4, unit: "credits" }], + }, + ], + }, + { + updatedAt: 2, + providers: [ + { + provider: "openai", + displayName: "Codex", + windows: [], + error: "Unavailable", + }, + ], + }, + ); + + expect(merged.providers[0]).toMatchObject({ + displayName: "OpenAI", + billing: [{ type: "balance", amount: 4, unit: "credits" }], + error: undefined, + }); + }); +}); diff --git a/src/status/codex-synthetic-usage.ts b/src/status/codex-synthetic-usage.ts index 0a4c0d96fcb4..b9d81a7b4b0e 100644 --- a/src/status/codex-synthetic-usage.ts +++ b/src/status/codex-synthetic-usage.ts @@ -1,7 +1,11 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; import { CODEX_APP_SERVER_AUTH_MARKER } from "../agents/model-auth-markers.js"; import type { ProviderAuth } from "../infra/provider-usage.auth.js"; -import type { ProviderUsageSnapshot, UsageSummary } from "../infra/provider-usage.types.js"; +import type { + ProviderUsageBilling, + ProviderUsageSnapshot, + UsageSummary, +} from "../infra/provider-usage.types.js"; export const CODEX_SYNTHETIC_USAGE_PROVIDER = "openai"; export const CODEX_SYNTHETIC_USAGE_HOOK_PROVIDER = "codex"; @@ -52,7 +56,11 @@ export function shouldUseCodexSyntheticUsageForRuntime(params: { } function hasDisplayableUsageSnapshot(snapshot: ProviderUsageSnapshot): boolean { - return snapshot.windows.length > 0 || Boolean(snapshot.summary?.trim()); + return ( + snapshot.windows.length > 0 || + Boolean(snapshot.billing?.length) || + Boolean(snapshot.summary?.trim()) + ); } function usageSnapshotRank(snapshot: ProviderUsageSnapshot): number { @@ -62,6 +70,51 @@ function usageSnapshotRank(snapshot: ProviderUsageSnapshot): number { return snapshot.error ? 0 : 1; } +function billingEntryKey(entry: ProviderUsageBilling): string { + const period = "period" in entry ? (entry.period ?? "") : ""; + return [entry.type, entry.label ?? "", entry.unit, period].join("\0"); +} + +function mergeBilling( + preferred: ProviderUsageSnapshot, + secondary: ProviderUsageSnapshot, +): ProviderUsageBilling[] | undefined { + const entries = new Map(); + for (const entry of secondary.billing ?? []) { + entries.set(billingEntryKey(entry), entry); + } + for (const entry of preferred.billing ?? []) { + entries.set(billingEntryKey(entry), entry); + } + return entries.size > 0 ? [...entries.values()] : undefined; +} + +function mergeUsageSnapshots( + preferred: ProviderUsageSnapshot, + secondary: ProviderUsageSnapshot, +): ProviderUsageSnapshot { + const billing = mergeBilling(preferred, secondary); + // Synthetic and OAuth sources can own different facts for the same provider. + // Preserve complementary plan/billing data while the preferred source owns windows/errors. + return { + ...secondary, + ...preferred, + windows: preferred.windows.length > 0 ? preferred.windows : secondary.windows, + ...(billing ? { billing } : {}), + ...(preferred.summary?.trim() + ? { summary: preferred.summary } + : secondary.summary?.trim() + ? { summary: secondary.summary } + : {}), + ...(preferred.plan?.trim() + ? { plan: preferred.plan } + : secondary.plan?.trim() + ? { plan: secondary.plan } + : {}), + ...(!preferred.error ? { error: undefined } : {}), + }; +} + export function mergeUsageSummaries( base: UsageSummary, extra: UsageSummary | undefined, @@ -72,9 +125,14 @@ export function mergeUsageSummaries( const providersById = new Map(base.providers.map((provider) => [provider.provider, provider])); for (const provider of extra.providers) { const existing = providersById.get(provider.provider); - if (!existing || usageSnapshotRank(provider) >= usageSnapshotRank(existing)) { + if (!existing) { providersById.set(provider.provider, provider); + continue; } + const preferred = + usageSnapshotRank(provider) >= usageSnapshotRank(existing) ? provider : existing; + const secondary = preferred === provider ? existing : provider; + providersById.set(provider.provider, mergeUsageSnapshots(preferred, secondary)); } return { updatedAt: base.updatedAt, diff --git a/src/status/status-text.ts b/src/status/status-text.ts index 1a82faec3565..d64fdd492f0a 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -476,7 +476,9 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise 0 || Boolean(usageEntry.summary?.trim())) + (usageEntry.windows.length > 0 || + Boolean(usageEntry.billing?.length) || + Boolean(usageEntry.summary?.trim())) ) { const summaryLine = formatUsageWindowSummary(usageEntry, { now: Date.now(), diff --git a/ui/src/e2e/usage-cost-analysis.e2e.test.ts b/ui/src/e2e/usage-cost-analysis.e2e.test.ts index 317a2954a484..d13d72a9b1b7 100644 --- a/ui/src/e2e/usage-cost-analysis.e2e.test.ts +++ b/ui/src/e2e/usage-cost-analysis.e2e.test.ts @@ -178,6 +178,43 @@ describeControlUiE2e("Control UI usage cost analysis mocked Gateway E2E", () => daily, totals, }, + "usage.status": { + updatedAt: Date.now(), + providers: [ + { + provider: "openai", + displayName: "OpenAI", + plan: "Plus", + windows: [ + { label: "5h", usedPercent: 35 }, + { label: "Week", usedPercent: 62 }, + ], + billing: [{ type: "balance", amount: 18, unit: "credits" }], + }, + { + provider: "openrouter", + displayName: "OpenRouter", + plan: "Production", + windows: [{ label: "API key budget", usedPercent: 25 }], + billing: [ + { + type: "balance", + label: "Account balance", + amount: 64.5, + unit: "USD", + }, + { + type: "budget", + label: "API key budget", + used: 5, + limit: 20, + unit: "USD", + }, + ], + summary: "$1.25 today · $5.00 this month", + }, + ], + }, }, }); @@ -209,16 +246,27 @@ describeControlUiE2e("Control UI usage cost analysis mocked Gateway E2E", () => await expect .poll(() => page.locator(".usage-insight-card", { hasText: "Top Providers" }).textContent()) .toContain("openai"); + const providerCards = page.locator(".provider-usage-card"); + await expect.poll(() => providerCards.count()).toBe(2); + await expect + .poll(async () => (await gateway.getRequests("usage.status")).length) + .toBeGreaterThan(0); + await expect + .poll(() => providerCards.filter({ hasText: "OpenRouter" }).textContent()) + .toContain("$64.50"); + await expect + .poll(() => providerCards.filter({ hasText: "OpenAI" }).textContent()) + .toContain("18 credits"); if (process.env.OPENCLAW_CAPTURE_UI_PROOF === "1") { const artifactDir = path.join( process.cwd(), ".artifacts", "control-ui-e2e", - "cost-analysis", + "provider-plans", ); await mkdir(artifactDir, { recursive: true }); - await page.locator(".usage-left-card").screenshot({ + await page.locator(".usage-page").screenshot({ path: path.join(artifactDir, "after.png"), }); } diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index 90bc90070d5e..7800a6a3ef0f 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:42.131Z", + "generatedAt": "2026-07-06T02:52:32.580Z", "locale": "ar", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index a2f9c8fdd7ed..b5218d916715 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:38.969Z", + "generatedAt": "2026-07-06T02:52:31.655Z", "locale": "de", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 408f0b3a569b..3f2c110e995b 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:39.613Z", + "generatedAt": "2026-07-06T02:52:31.812Z", "locale": "es", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index e9a3541dc099..2fbd50e22295 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:46.944Z", + "generatedAt": "2026-07-06T02:52:33.895Z", "locale": "fa", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index 476705e0a0f2..94a28ea6df93 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:41.010Z", + "generatedAt": "2026-07-06T02:52:32.281Z", "locale": "fr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/hi.meta.json b/ui/src/i18n/.i18n/hi.meta.json index af246a4c72a7..865fe9326005 100644 --- a/ui/src/i18n/.i18n/hi.meta.json +++ b/ui/src/i18n/.i18n/hi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:41.548Z", + "generatedAt": "2026-07-06T02:52:32.424Z", "locale": "hi", - "model": "gpt-5.5", - "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "model": "claude-opus-4-8", + "provider": "anthropic", + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/hi.tm.jsonl b/ui/src/i18n/.i18n/hi.tm.jsonl index 021d02c570b1..686513c3b01d 100644 --- a/ui/src/i18n/.i18n/hi.tm.jsonl +++ b/ui/src/i18n/.i18n/hi.tm.jsonl @@ -448,6 +448,7 @@ {"cache_key":"4677042df4f414e8266008d2317415b07282a965faf81d510cbbb949bb77a5ef","model":"gpt-5.5","provider":"openai","segment_id":"usage.filters.tool","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Tool","text_hash":"2e53bdcd0740867b597599e733c04a994f55fb17c89a61595183a001742e5705","tgt_lang":"hi","translated":"Tool","updated_at":"2026-06-26T21:34:43.709Z"} {"cache_key":"46ddd616fb81810a8d4e1332c5116ab5109331ee9f14b04d1c0c9ca80070c305","model":"gpt-5.5","provider":"openai","segment_id":"activity.visibleCount","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"{visible} of {total}","text_hash":"9ba4e8a044fb7345bfed5e198ae4d10bcf326b845d2cecc7459c6739a81588af","tgt_lang":"hi","translated":"{total} में से {visible}","updated_at":"2026-06-26T21:31:44.582Z"} {"cache_key":"46dfb525ad3a9827dd206968e31af4342b576e1a06f9cfc7beba37a3b45f30d8","model":"gpt-5.5","provider":"openai","segment_id":"cron.jobState.status","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Status","text_hash":"920e413c7d411b61ef3e8c63b1cb6ad058d5f95f8b481dbafe60248387d8c355","tgt_lang":"hi","translated":"स्थिति","updated_at":"2026-06-26T21:38:12.145Z"} +{"cache_key":"47063e7a0fb876defe62abec64781498b64fd98b92286041a2ae1e7e9e2c4208","model":"gpt-5.5","provider":"openai","segment_id":"usage.providerUsage.spend","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Usage","text_hash":"8d59829c1e15afe1a7fae93e8e5e32d8511bec5fd598a09f4fea6033b31e8a66","tgt_lang":"hi","translated":"उपयोग","updated_at":"2026-06-26T21:31:23.820Z"} {"cache_key":"473dbe0c7e807538580696ab158adf18118f0fb581d53adb3c2cbc79f7f8a919","model":"gpt-5.5","provider":"openai","segment_id":"workboard.disabledHelpStart","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Workboard is disabled. Enable","text_hash":"10a5b9ffaec507bdc3516021c98c28fa81dfeca9f2dfddcbf3d65e19e0bb52cd","tgt_lang":"hi","translated":"Workboard अक्षम है। सक्षम करें","updated_at":"2026-06-26T21:31:58.515Z"} {"cache_key":"47467ca97ece424e64ccf36d5af810bc355254e156ee6eb123def9869349f0eb","model":"gpt-5.5","provider":"openai","segment_id":"workboard.eventClaimed","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Claimed","text_hash":"ddcd2779294a61f056090b2bbc47444816ff791ed0cf9ec295821e82a384ef81","tgt_lang":"hi","translated":"क्लेम किया गया","updated_at":"2026-06-26T21:33:00.304Z"} {"cache_key":"477ee018839037fceaa7d342343f2fc55ea21246d522212fa5f37215dc0a8c14","model":"gpt-5.5","provider":"openai","segment_id":"usage.details.searchConversation","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Search conversation","text_hash":"42c60071a9546a4a8e15a97ec5037957203d4a0e35e23cbc52664fc7bb189f61","tgt_lang":"hi","translated":"वार्तालाप खोजें","updated_at":"2026-06-26T21:35:33.329Z"} diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index 899b881157f9..c1851c77aadd 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:44.233Z", + "generatedAt": "2026-07-06T02:52:33.180Z", "locale": "id", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index 48ab0803a002..b45f41394000 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:42.630Z", + "generatedAt": "2026-07-06T02:52:32.736Z", "locale": "it", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index e9d737771de5..162293957acb 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:40.067Z", + "generatedAt": "2026-07-06T02:52:31.962Z", "locale": "ja-JP", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index a856615790be..8fd9d2c57c3b 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:40.501Z", + "generatedAt": "2026-07-06T02:52:32.120Z", "locale": "ko", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index e68dde7bee65..005860820b25 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:46.460Z", + "generatedAt": "2026-07-06T02:52:33.757Z", "locale": "nl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index 491616004522..58588b9f83a5 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:44.794Z", + "generatedAt": "2026-07-06T02:52:33.337Z", "locale": "pl", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index 832d91e34ac6..ba45759580a3 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:38.181Z", + "generatedAt": "2026-07-06T02:52:31.504Z", "locale": "pt-BR", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ru.meta.json b/ui/src/i18n/.i18n/ru.meta.json index 4efd06f313e6..dc6e5e6b00b5 100644 --- a/ui/src/i18n/.i18n/ru.meta.json +++ b/ui/src/i18n/.i18n/ru.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:47.548Z", + "generatedAt": "2026-07-06T02:52:34.046Z", "locale": "ru", - "model": "gpt-5.5", - "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "model": "claude-opus-4-8", + "provider": "anthropic", + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ru.tm.jsonl b/ui/src/i18n/.i18n/ru.tm.jsonl index 9f607030854e..3795f291241c 100644 --- a/ui/src/i18n/.i18n/ru.tm.jsonl +++ b/ui/src/i18n/.i18n/ru.tm.jsonl @@ -430,6 +430,7 @@ {"cache_key":"440629c6f49656542d099fbeb9a8e4d1d2ff38218eb654f1489dd6a31da24125","model":"gpt-5.5","provider":"openai","segment_id":"chat.composer.talkTranscript","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Talk transcript","text_hash":"b3a845b53c03efcdf581409dfe13d383dc387c32451325677a7dcc7d7402d48b","tgt_lang":"ru","translated":"Расшифровка Talk","updated_at":"2026-06-26T21:41:59.944Z"} {"cache_key":"440f22d4dff81d04ea95679c276ebc9cd79e9a90987b77b1283caf989f86f25e","model":"gpt-5.5","provider":"openai","segment_id":"activity.visibleCount","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"{visible} of {total}","text_hash":"9ba4e8a044fb7345bfed5e198ae4d10bcf326b845d2cecc7459c6739a81588af","tgt_lang":"ru","translated":"{visible} из {total}","updated_at":"2026-06-26T21:39:31.958Z"} {"cache_key":"442b6d355d4a2880a8e7fd52385744ec5e5cf17a5ac7b0735c4c713e3ccbf665","model":"gpt-5.5","provider":"openai","segment_id":"dreaming.status.active","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Dreaming Active","text_hash":"fd7a73177f09d63e4afe11f3ac6e028368eb1c3163b80022a9bf46b94e1b658a","tgt_lang":"ru","translated":"Dreaming активен","updated_at":"2026-06-26T21:40:41.209Z"} +{"cache_key":"444bbd24ad61cd4cc6dd36e8c9ab64c1091dd57a00c8584f15c60f1867c7ef09","model":"gpt-5.5","provider":"openai","segment_id":"usage.providerUsage.spend","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Usage","text_hash":"8d59829c1e15afe1a7fae93e8e5e32d8511bec5fd598a09f4fea6033b31e8a66","tgt_lang":"ru","translated":"Использование","updated_at":"2026-06-26T21:39:23.906Z"} {"cache_key":"446a4b79e9b2a5b36daa3d2bb54a81f940a2c4787db3f9d5d4799c9ed67042b1","model":"gpt-5.5","provider":"openai","segment_id":"instances.subtitle","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Presence beacons from the gateway and clients.","text_hash":"5349f6c160fabe02b9b0d3065e8cd995704de9fcb2894945af4660d9cb35f666","tgt_lang":"ru","translated":"Сигналы присутствия от шлюза и клиентов.","updated_at":"2026-06-26T21:38:48.084Z"} {"cache_key":"44b0b7777c44afeda428859b0add2cea3af7c4307eef4fcf0d0d633fe599de36","model":"gpt-5.5","provider":"openai","segment_id":"debug.security.audit","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Security audit","text_hash":"7efbf2205196ca1f7458f4e84625d163db12b4401d478dd631785b33668cc6c5","tgt_lang":"ru","translated":"Аудит безопасности","updated_at":"2026-06-26T21:39:14.470Z"} {"cache_key":"4506bafb7b570711cbacfe77a51a7e37c8512f7376b9de3d71efc39771c5c0d1","model":"gpt-5.5","provider":"openai","segment_id":"usage.breakdown.costByType","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Cost by Type","text_hash":"191407927e3b9ed0accd8cc9d2b8952704dfd9a8cc6edfe8c04a722e146fe612","tgt_lang":"ru","translated":"Стоимость по типу","updated_at":"2026-06-26T21:41:12.411Z"} diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index 52cd1d21bba1..ee60ae40a02e 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:45.439Z", + "generatedAt": "2026-07-06T02:52:33.471Z", "locale": "th", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index f0c73f260b1d..9cb6495d1572 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:43.168Z", + "generatedAt": "2026-07-06T02:52:32.880Z", "locale": "tr", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index cf03ddc2ff21..75ef03577ae0 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:43.632Z", + "generatedAt": "2026-07-06T02:52:33.034Z", "locale": "uk", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 13b15c825138..ac9afb46ec78 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:45.913Z", + "generatedAt": "2026-07-06T02:52:33.606Z", "locale": "vi", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 8f258a91f65a..558e73b21aba 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:36.794Z", + "generatedAt": "2026-07-06T02:52:31.201Z", "locale": "zh-CN", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index ec4b9730685b..a8e7ceb3ca4b 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-05T23:49:37.639Z", + "generatedAt": "2026-07-06T02:52:31.344Z", "locale": "zh-TW", "model": "gpt-5.5", "provider": "openai", - "sourceHash": "448364fb3ef0e4961429e391b8269a0369507307cd3f4e84e8e9eacd9e20e44b", - "totalKeys": 1539, - "translatedKeys": 1539, + "sourceHash": "fd605f57f77602adfa2c47b280122a4b50fa0b096663145e45ff55aea994f1f3", + "totalKeys": 1546, + "translatedKeys": 1546, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index 0bac87f6053a..dadfeedb05a8 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -1118,6 +1118,15 @@ export const ar: TranslationMap = { session: "جلسة", sessions: "جلسات", }, + providerUsage: { + title: "خطط المزوّدين والفوترة", + subtitle: "بيانات مباشرة للخطة والحصة والرصيد والميزانية من المزوّدين الذين تم إعدادهم.", + balance: "الرصيد", + spend: "الاستخدام", + budget: "الميزانية", + remaining: "متبقٍ {percent}%", + resets: "تتم إعادة التعيين في {date}", + }, presets: { today: "اليوم", last7d: "7 أيام", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index e8001d651448..048597481e59 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -1134,6 +1134,16 @@ export const de: TranslationMap = { session: "Sitzung", sessions: "Sitzungen", }, + providerUsage: { + title: "Anbietertarife & Abrechnung", + subtitle: + "Live-Daten zu Tarif, Kontingent, Guthaben und Budget von konfigurierten Anbietern.", + balance: "Guthaben", + spend: "Nutzung", + budget: "Budget", + remaining: "{percent}% übrig", + resets: "Zurücksetzung am {date}", + }, presets: { today: "Heute", last7d: "7d", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index d3051f134c1f..124e2497aa9c 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1120,6 +1120,15 @@ export const en: TranslationMap = { session: "session", sessions: "sessions", }, + providerUsage: { + title: "Provider plans & billing", + subtitle: "Live plan, quota, balance, and budget data reported by configured providers.", + balance: "Balance", + spend: "Usage", + budget: "Budget", + remaining: "{percent}% left", + resets: "Resets {date}", + }, presets: { today: "Today", last7d: "7d", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index e74446c630b0..bb4ab19df3d9 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -1131,6 +1131,16 @@ export const es: TranslationMap = { session: "sesión", sessions: "sesiones", }, + providerUsage: { + title: "Planes y facturación de proveedores", + subtitle: + "Datos en tiempo real de planes, cuotas, saldos y presupuestos proporcionados por los proveedores configurados.", + balance: "Saldo", + spend: "Uso", + budget: "Presupuesto", + remaining: "Queda un {percent}%", + resets: "Se restablece el {date}", + }, presets: { today: "Hoy", last7d: "7d", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index 9dd67435d668..98dcf8f27bed 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -1126,6 +1126,15 @@ export const fa: TranslationMap = { session: "نشست", sessions: "نشست‌ها", }, + providerUsage: { + title: "طرح‌ها و صورت‌حساب ارائه‌دهندگان", + subtitle: "داده‌های زندهٔ طرح، سهمیه، موجودی و بودجه که ارائه‌دهندگان پیکربندی‌شده گزارش می‌کنند.", + balance: "موجودی", + spend: "مصرف", + budget: "بودجه", + remaining: "{percent}٪ باقی‌مانده", + resets: "بازنشانی در {date}", + }, presets: { today: "امروز", last7d: "۷روز", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index 84be39190590..c3884806c9b1 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -1133,6 +1133,16 @@ export const fr: TranslationMap = { session: "session", sessions: "sessions", }, + providerUsage: { + title: "Forfaits et facturation des fournisseurs", + subtitle: + "Données en temps réel sur les forfaits, quotas, soldes et budgets communiquées par les fournisseurs configurés.", + balance: "Solde", + spend: "Utilisation", + budget: "Budget", + remaining: "{percent}% restant", + resets: "Réinitialisation le {date}", + }, presets: { today: "Aujourd’hui", last7d: "7 j", diff --git a/ui/src/i18n/locales/hi.ts b/ui/src/i18n/locales/hi.ts index 59286cb7ab7a..675d5c2ebe69 100644 --- a/ui/src/i18n/locales/hi.ts +++ b/ui/src/i18n/locales/hi.ts @@ -1122,6 +1122,15 @@ export const hi: TranslationMap = { session: "सेशन", sessions: "सेशन", }, + providerUsage: { + title: "प्रदाता प्लान और बिलिंग", + subtitle: "कॉन्फ़िगर किए गए प्रदाताओं से प्राप्त प्लान, कोटा, शेष राशि और बजट का लाइव डेटा।", + balance: "शेष राशि", + spend: "उपयोग", + budget: "बजट", + remaining: "{percent}% शेष", + resets: "{date} को रीसेट", + }, presets: { today: "आज", last7d: "7d", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 059477316878..59b93598a6db 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -1126,6 +1126,16 @@ export const id: TranslationMap = { session: "sesi", sessions: "sesi", }, + providerUsage: { + title: "Paket & penagihan penyedia", + subtitle: + "Data paket, kuota, saldo, dan anggaran langsung yang dilaporkan oleh penyedia yang dikonfigurasi.", + balance: "Saldo", + spend: "Penggunaan", + budget: "Anggaran", + remaining: "Tersisa {percent}%", + resets: "Direset {date}", + }, presets: { today: "Hari ini", last7d: "7h", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index 36d13a97df97..6770c25cf20d 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -1130,6 +1130,16 @@ export const it: TranslationMap = { session: "sessione", sessions: "sessioni", }, + providerUsage: { + title: "Piani e fatturazione dei provider", + subtitle: + "Dati in tempo reale su piano, quota, saldo e budget comunicati dai provider configurati.", + balance: "Saldo", + spend: "Utilizzo", + budget: "Budget", + remaining: "{percent}% rimanente", + resets: "Reimpostazione il {date}", + }, presets: { today: "Oggi", last7d: "7 gg", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index fd541c1ea74c..c78f3ab7036b 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -1130,6 +1130,16 @@ export const ja_JP: TranslationMap = { session: "セッション", sessions: "セッション", }, + providerUsage: { + title: "プロバイダーのプランと請求", + subtitle: + "設定済みプロバイダーから取得したプラン、クォータ、残高、予算のリアルタイムデータ。", + balance: "残高", + spend: "使用量", + budget: "予算", + remaining: "残り {percent}%", + resets: "{date} にリセット", + }, presets: { today: "今日", last7d: "7日", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index 47a163bdb438..8bb99971e367 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -1122,6 +1122,15 @@ export const ko: TranslationMap = { session: "세션", sessions: "세션", }, + providerUsage: { + title: "제공업체 요금제 및 결제", + subtitle: "설정된 제공업체에서 보고한 요금제, 할당량, 잔액 및 예산의 실시간 데이터입니다.", + balance: "잔액", + spend: "사용량", + budget: "예산", + remaining: "{percent}% 남음", + resets: "{date}에 초기화", + }, presets: { today: "오늘", last7d: "7일", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 01503e5cb9fd..d176c1231dd9 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -1130,6 +1130,16 @@ export const nl: TranslationMap = { session: "sessie", sessions: "sessies", }, + providerUsage: { + title: "Providerabonnementen en facturering", + subtitle: + "Livegegevens over abonnementen, quota, saldo en budget, gerapporteerd door geconfigureerde providers.", + balance: "Saldo", + spend: "Gebruik", + budget: "Budget", + remaining: "{percent}% resterend", + resets: "Wordt opnieuw ingesteld op {date}", + }, presets: { today: "Vandaag", last7d: "7d", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index 21eadab4db42..28c1057d8556 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -1129,6 +1129,16 @@ export const pl: TranslationMap = { session: "sesja", sessions: "sesje", }, + providerUsage: { + title: "Plany i rozliczenia dostawców", + subtitle: + "Aktualne dane o planie, limicie, saldzie i budżecie zgłaszane przez skonfigurowanych dostawców.", + balance: "Saldo", + spend: "Użycie", + budget: "Budżet", + remaining: "Pozostało {percent}%", + resets: "Reset: {date}", + }, presets: { today: "Dzisiaj", last7d: "7d", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 66205f1261c6..5661341e057e 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -1127,6 +1127,16 @@ export const pt_BR: TranslationMap = { session: "sessão", sessions: "sessões", }, + providerUsage: { + title: "Planos e faturamento dos provedores", + subtitle: + "Dados em tempo real de plano, cota, saldo e orçamento informados pelos provedores configurados.", + balance: "Saldo", + spend: "Uso", + budget: "Orçamento", + remaining: "{percent}% restante", + resets: "Redefine em {date}", + }, presets: { today: "Hoje", last7d: "7d", diff --git a/ui/src/i18n/locales/ru.ts b/ui/src/i18n/locales/ru.ts index 0239c326e77f..18124667cd53 100644 --- a/ui/src/i18n/locales/ru.ts +++ b/ui/src/i18n/locales/ru.ts @@ -1133,6 +1133,16 @@ export const ru: TranslationMap = { session: "сеанс", sessions: "сеансы", }, + providerUsage: { + title: "Тарифы и расчёты провайдеров", + subtitle: + "Актуальные данные о тарифах, квотах, балансе и бюджете от настроенных провайдеров.", + balance: "Баланс", + spend: "Использование", + budget: "Бюджет", + remaining: "Осталось {percent}%", + resets: "Сброс {date}", + }, presets: { today: "Сегодня", last7d: "7 дн.", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index fb7cbcd3be63..98129d44540b 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -1114,6 +1114,15 @@ export const th: TranslationMap = { session: "เซสชัน", sessions: "เซสชัน", }, + providerUsage: { + title: "แพ็กเกจและการเรียกเก็บเงินของผู้ให้บริการ", + subtitle: "ข้อมูลแพ็กเกจ โควตา ยอดคงเหลือ และงบประมาณแบบเรียลไทม์ที่รายงานโดยผู้ให้บริการที่กำหนดค่าไว้", + balance: "ยอดคงเหลือ", + spend: "การใช้งาน", + budget: "งบประมาณ", + remaining: "เหลือ {percent}%", + resets: "รีเซ็ต {date}", + }, presets: { today: "วันนี้", last7d: "7 วัน", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 34c9d2a1e5a2..b8cfca474e11 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -1130,6 +1130,16 @@ export const tr: TranslationMap = { session: "oturum", sessions: "oturumlar", }, + providerUsage: { + title: "Sağlayıcı planları ve faturalandırma", + subtitle: + "Yapılandırılmış sağlayıcıların bildirdiği canlı plan, kota, bakiye ve bütçe verileri.", + balance: "Bakiye", + spend: "Kullanım", + budget: "Bütçe", + remaining: "{percent}% kaldı", + resets: "{date} tarihinde sıfırlanır", + }, presets: { today: "Bugün", last7d: "7g", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index 531de885bf36..52ae4ce30221 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -1131,6 +1131,15 @@ export const uk: TranslationMap = { session: "сеанс", sessions: "сеанси", }, + providerUsage: { + title: "Плани провайдерів і оплата", + subtitle: "Актуальні дані про план, квоту, баланс і бюджет від налаштованих провайдерів.", + balance: "Баланс", + spend: "Використання", + budget: "Бюджет", + remaining: "Залишилося {percent}%", + resets: "Скидання {date}", + }, presets: { today: "Сьогодні", last7d: "7 дн.", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 07b3584a7f4c..2751a22ef534 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -1124,6 +1124,16 @@ export const vi: TranslationMap = { session: "phiên", sessions: "phiên", }, + providerUsage: { + title: "Gói dịch vụ & thanh toán của nhà cung cấp", + subtitle: + "Dữ liệu trực tiếp về gói dịch vụ, hạn mức, số dư và ngân sách do các nhà cung cấp đã cấu hình báo cáo.", + balance: "Số dư", + spend: "Mức sử dụng", + budget: "Ngân sách", + remaining: "Còn {percent}%", + resets: "Đặt lại vào {date}", + }, presets: { today: "Hôm nay", last7d: "7 ngày", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 304402067b63..6935ea284aed 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -1113,6 +1113,15 @@ export const zh_CN: TranslationMap = { session: "会话", sessions: "会话", }, + providerUsage: { + title: "供应商套餐与计费", + subtitle: "已配置供应商报告的实时套餐、配额、余额和预算数据。", + balance: "余额", + spend: "用量", + budget: "预算", + remaining: "剩余 {percent}%", + resets: "于 {date} 重置", + }, presets: { today: "今天", last7d: "7天", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 906620d93854..6b4a9cd882ad 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -1113,6 +1113,15 @@ export const zh_TW: TranslationMap = { session: "工作階段", sessions: "工作階段", }, + providerUsage: { + title: "供應商方案與計費", + subtitle: "已設定供應商回報的即時方案、配額、餘額與預算資料。", + balance: "餘額", + spend: "用量", + budget: "預算", + remaining: "剩餘 {percent}%", + resets: "於 {date} 重設", + }, presets: { today: "今天", last7d: "7 天", diff --git a/ui/src/pages/usage/data-types.ts b/ui/src/pages/usage/data-types.ts index 5080ff591afe..10de6eabedd6 100644 --- a/ui/src/pages/usage/data-types.ts +++ b/ui/src/pages/usage/data-types.ts @@ -1,3 +1,4 @@ +import type { UsageSummary as SharedProviderUsageSummary } from "../../../../src/infra/provider-usage.types.js"; // Control UI module implements usage types behavior. import type { SessionUsageTimePoint as SharedSessionUsageTimePoint, @@ -8,6 +9,7 @@ import type { SessionsUsageResult as SharedSessionsUsageResult } from "../../../ export type SessionsUsageEntry = SharedSessionsUsageResult["sessions"][number]; export type SessionsUsageTotals = SharedSessionsUsageResult["totals"]; export type SessionsUsageResult = SharedSessionsUsageResult; +export type ProviderUsageSummary = SharedProviderUsageSummary; export type CostUsageDailyEntry = SessionsUsageTotals & { date: string }; diff --git a/ui/src/pages/usage/route.ts b/ui/src/pages/usage/route.ts index e421f658b72f..17429ead0119 100644 --- a/ui/src/pages/usage/route.ts +++ b/ui/src/pages/usage/route.ts @@ -7,6 +7,7 @@ import { isMissingOperatorReadScopeError, } from "../../lib/gateway-errors.ts"; import { buildSessionUsageDateParams, requestSessionUsage } from "../../lib/sessions/index.ts"; +import type { ProviderUsageSummary } from "./data-types.ts"; import type { UsageRouteData } from "./usage-page.ts"; function currentLocalDate(): string { @@ -41,12 +42,13 @@ async function loadUsageRouteData(context: ApplicationContext): Promise("usage.status").catch(() => null), ]); return { client: gateway.client, @@ -64,6 +67,7 @@ async function loadUsageRouteData(context: ApplicationContext): Promise("usage.cost", { startDate, @@ -309,12 +314,14 @@ export class UsagePage extends LitElement { ...agentScopeParams, ...buildSessionUsageDateParams(timeZone), }), + client.request("usage.status").catch(() => null), ]); if (!this.isCurrentRequest(requestId, client)) { return; } this.usageResult = sessionsResult; this.usageCostSummary = costSummary; + this.providerUsageSummary = providerUsageSummary; } catch (error) { if (!this.isCurrentRequest(requestId, client)) { return; @@ -465,6 +472,7 @@ export class UsagePage extends LitElement { this.usageResult?.cacheStatus, this.usageCostSummary?.cacheStatus, ), + providerUsage: this.providerUsageSummary?.providers ?? [], }, filters: { startDate: this.usageStartDate, diff --git a/ui/src/pages/usage/view.test.ts b/ui/src/pages/usage/view.test.ts index af40b6b3ed55..1b0779b66c15 100644 --- a/ui/src/pages/usage/view.test.ts +++ b/ui/src/pages/usage/view.test.ts @@ -19,6 +19,7 @@ function createUsageProps(overrides: Partial = {}): UsageProps { aggregates: null, costDaily: [], cacheStatus: undefined, + providerUsage: [], }, filters: { startDate: "2026-05-14", @@ -145,6 +146,51 @@ describe("renderUsage", () => { expect(agentFilter?.textContent).toContain("research"); }); + it("renders provider plans, quotas, and billing independently of session usage", () => { + const container = document.createElement("div"); + + render( + renderUsage( + createUsageProps({ + data: { + ...createUsageProps().data, + providerUsage: [ + { + provider: "openrouter", + displayName: "OpenRouter", + plan: "Production", + windows: [{ label: "API key budget", usedPercent: 25 }], + billing: [ + { + type: "balance", + label: "Account balance", + amount: 64.5, + unit: "USD", + }, + { + type: "budget", + label: "API key budget", + used: 5, + limit: 20, + unit: "USD", + }, + ], + }, + ], + }, + }), + ), + container, + ); + + const card = container.querySelector(".provider-usage-card"); + expect(card?.textContent).toContain("OpenRouter"); + expect(card?.textContent).toContain("Production"); + expect(card?.textContent).toContain("75% left"); + expect(card?.textContent).toContain("$64.50"); + expect(card?.textContent).toContain("$5.00 / $20.00"); + }); + it("filters visible sessions when an agent scope is selected", () => { const container = document.createElement("div"); diff --git a/ui/src/pages/usage/view.ts b/ui/src/pages/usage/view.ts index bfce732a57a7..d4f0368c5f6d 100644 --- a/ui/src/pages/usage/view.ts +++ b/ui/src/pages/usage/view.ts @@ -3,6 +3,7 @@ import { html, nothing } from "lit"; import { t } from "../../i18n/index.ts"; import "../../components/tooltip.ts"; import { getUsageCacheRefreshTitle } from "./cache-status.ts"; +import type { ProviderUsageSummary } from "./data-types.ts"; import { extractQueryTerms, filterSessionsByQuery } from "./helpers.ts"; import { buildAggregatesFromSessions, @@ -137,6 +138,138 @@ function renderUsageEmptyState(onRefresh: () => void) { `; } +type ProviderUsageSnapshot = ProviderUsageSummary["providers"][number]; + +function formatProviderAmount(amount: number, unit: string): string { + const normalizedUnit = unit.trim().toUpperCase(); + if (["USD", "EUR", "GBP", "CNY", "JPY"].includes(normalizedUnit)) { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: normalizedUnit, + maximumFractionDigits: normalizedUnit === "JPY" ? 0 : 2, + }).format(amount); + } + return `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(amount)} ${unit}`; +} + +function formatProviderReset(resetAt: number | undefined): string | null { + if (!resetAt || !Number.isFinite(resetAt)) { + return null; + } + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }).format(new Date(resetAt)); +} + +function renderProviderBilling(snapshot: ProviderUsageSnapshot) { + return (snapshot.billing ?? []).map((entry) => { + const label = + entry.label ?? + (entry.type === "balance" + ? t("usage.providerUsage.balance") + : entry.type === "spend" + ? t("usage.providerUsage.spend") + : t("usage.providerUsage.budget")); + const value = + entry.type === "budget" + ? `${formatProviderAmount(entry.used, entry.unit)} / ${formatProviderAmount(entry.limit, entry.unit)}` + : formatProviderAmount(entry.amount, entry.unit); + return html` +
+ ${label} + ${value} +
+ `; + }); +} + +function renderProviderUsage(providers: ProviderUsageSnapshot[]) { + if (providers.length === 0) { + return nothing; + } + return html` +
+
+
+
${t("usage.providerUsage.title")}
+
${t("usage.providerUsage.subtitle")}
+
+ ${providers.length} +
+
+ ${providers.map( + (provider) => html` +
+
+
+
${provider.displayName}
+
${provider.provider}
+
+ ${provider.plan + ? html`${provider.plan}` + : nothing} +
+ ${provider.error + ? html`
${provider.error}
` + : html` + ${provider.windows.length > 0 + ? html` +
+ ${provider.windows.map((window) => { + const used = Math.max(0, Math.min(100, window.usedPercent)); + const remaining = Math.max(0, 100 - used); + const reset = formatProviderReset(window.resetAt); + return html` +
+
+ ${window.label} + ${t("usage.providerUsage.remaining", { + percent: remaining.toFixed(0), + })} +
+
+ +
+ ${reset + ? html`
+ ${t("usage.providerUsage.resets", { date: reset })} +
` + : nothing} +
+ `; + })} +
+ ` + : nothing} + ${provider.billing && provider.billing.length > 0 + ? html`
+ ${renderProviderBilling(provider)} +
` + : nothing} + ${provider.summary + ? html`
${provider.summary}
` + : nothing} + `} +
+ `, + )} +
+
+ `; +} + function closeDetailsOnOutsideClick(e: Event) { const el = e.currentTarget as HTMLDetailsElement; if (!el.open) { @@ -789,6 +922,7 @@ export function renderUsage(props: UsageProps) { : nothing} + ${renderProviderUsage(data.providerUsage)} ${isEmpty ? renderUsageEmptyState(filterActions.onRefresh) : html` diff --git a/ui/src/styles/usage.css b/ui/src/styles/usage.css index c086a8464d24..31e9a659eff8 100644 --- a/ui/src/styles/usage.css +++ b/ui/src/styles/usage.css @@ -29,6 +29,7 @@ .usage-loading-card, .usage-empty-state, .usage-overview-card, +.provider-usage-section, .usage-mosaic, .usage-left-card, .sessions-card, @@ -37,6 +38,127 @@ overflow: clip; } +.provider-usage-section { + display: grid; + gap: 14px; +} + +.provider-usage-heading, +.provider-usage-card__header, +.provider-usage-window__meta, +.provider-usage-billing-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.provider-usage-count, +.provider-usage-plan { + display: inline-flex; + align-items: center; + min-height: 24px; + padding: 3px 9px; + border-radius: var(--radius-full); + border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border)); + background: color-mix(in srgb, var(--accent) 8%, var(--bg-muted)); + color: var(--accent); + font-size: 11px; + font-weight: 650; +} + +.provider-usage-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); + gap: 10px; +} + +.provider-usage-card { + display: grid; + align-content: start; + gap: 14px; + min-width: 0; + padding: 14px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--card) 96%, var(--bg-elevated)) 0%, + color-mix(in srgb, var(--card) 90%, var(--bg-muted)) 100% + ); +} + +.provider-usage-card__header { + align-items: flex-start; +} + +.provider-usage-card__name { + color: var(--text-strong); + font-size: 14px; + font-weight: 700; +} + +.provider-usage-card__id, +.provider-usage-reset, +.provider-usage-summary { + color: var(--muted); + font-size: 11px; +} + +.provider-usage-plan { + flex: 0 1 auto; + max-width: 55%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-usage-windows, +.provider-usage-window, +.provider-usage-billing { + display: grid; + gap: 8px; +} + +.provider-usage-window__meta, +.provider-usage-billing-row { + font-size: 12px; +} + +.provider-usage-window__meta strong, +.provider-usage-billing-row strong { + color: var(--text-strong); + font-variant-numeric: tabular-nums; +} + +.provider-usage-progress { + height: 6px; + overflow: hidden; + border-radius: var(--radius-full); + background: color-mix(in srgb, var(--border) 70%, var(--bg-muted)); +} + +.provider-usage-progress span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient( + 90deg, + var(--accent), + color-mix(in srgb, var(--accent) 65%, var(--warn)) + ); +} + +.provider-usage-billing { + padding-top: 10px; + border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent); +} + +.provider-usage-error { + color: var(--danger); + font-size: 12px; +} + .usage-header { position: relative; overflow: visible;