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
This commit is contained in:
Peter Steinberger
2026-07-06 04:53:09 +01:00
committed by GitHub
parent a4261cac4d
commit 07e2d633cc
104 changed files with 2130 additions and 164 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.
</Tab>
</Tabs>

View File

@@ -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.
</Accordion>
<Accordion title="Usage">
- 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.
</Accordion>
<Accordion title="Debug, logs, update">
- Debug: status/health/models snapshots, event log, and manual RPC calls (`status`, `health`, `models.list`).

View File

@@ -243,7 +243,8 @@
}
],
"contracts": {
"mediaUnderstandingProviders": ["anthropic"]
"mediaUnderstandingProviders": ["anthropic"],
"usageProviders": ["anthropic"]
},
"mediaUnderstandingProviderMetadata": {
"anthropic": {

View File

@@ -5,6 +5,9 @@
},
"enabledByDefault": true,
"providers": ["clawrouter"],
"contracts": {
"usageProviders": ["clawrouter"]
},
"setup": {
"providers": [
{

View File

@@ -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 () => {

View File

@@ -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",
};

View File

@@ -7,6 +7,9 @@
"enabledByDefault": true,
"providerCatalogEntry": "./provider-discovery.ts",
"providers": ["deepseek"],
"contracts": {
"usageProviders": ["deepseek"]
},
"providerEndpoints": [
{
"endpointClass": "deepseek-native",

View File

@@ -153,7 +153,8 @@
}
},
"contracts": {
"memoryEmbeddingProviders": ["github-copilot"]
"memoryEmbeddingProviders": ["github-copilot"],
"usageProviders": ["github-copilot"]
},
"setup": {
"providers": [

View File

@@ -674,7 +674,8 @@
"realtimeVoiceProviders": ["google"],
"speechProviders": ["google"],
"videoGenerationProviders": ["google"],
"webSearchProviders": ["gemini"]
"webSearchProviders": ["gemini"],
"usageProviders": ["google-gemini-cli"]
},
"mediaUnderstandingProviderMetadata": {
"google": {

View File

@@ -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"]

View File

@@ -326,7 +326,8 @@
"memoryEmbeddingProviders": ["openai"],
"mediaUnderstandingProviders": ["openai"],
"imageGenerationProviders": ["openai"],
"videoGenerationProviders": ["openai"]
"videoGenerationProviders": ["openai"],
"usageProviders": ["openai"]
},
"imageGenerationProviderMetadata": {
"openai": {

View File

@@ -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"]);

View File

@@ -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());

View File

@@ -77,7 +77,8 @@
"imageGenerationProviders": ["openrouter"],
"musicGenerationProviders": ["openrouter"],
"videoGenerationProviders": ["openrouter"],
"speechProviders": ["openrouter"]
"speechProviders": ["openrouter"],
"usageProviders": ["openrouter"]
},
"mediaUnderstandingProviderMetadata": {
"openrouter": {

View File

@@ -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([]);
});
});

View File

@@ -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<string, unknown> }
| { 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<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<unknown> {
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<EndpointResult> {
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<ProviderUsageSnapshot> {
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<ProviderUsageSnapshot["billing"]> = [];
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 } : {}),
};
}

View File

@@ -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);

View File

@@ -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,
}),
},
});

View File

@@ -5,6 +5,9 @@
},
"enabledByDefault": true,
"providers": ["venice"],
"contracts": {
"usageProviders": ["venice"]
},
"providerAuthChoices": [
{
"provider": "venice",

View File

@@ -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([]);
});
});

128
extensions/venice/usage.ts Normal file
View File

@@ -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<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
async function readPayload(response: Response, timeoutMs: number): Promise<VeniceBalanceResponse> {
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<ProviderUsageSnapshot> {
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<ProviderUsageSnapshot["billing"]> = [];
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" } : {}),
};
}

View File

@@ -128,7 +128,8 @@
}
],
"contracts": {
"speechProviders": ["xiaomi"]
"speechProviders": ["xiaomi"],
"usageProviders": ["xiaomi", "xiaomi-token-plan"]
},
"providerAuthEnvVars": {
"xiaomi": ["XIAOMI_API_KEY"],

View File

@@ -328,7 +328,8 @@
}
],
"contracts": {
"mediaUnderstandingProviders": ["zai"]
"mediaUnderstandingProviders": ["zai"],
"usageProviders": ["zai"]
},
"mediaUnderstandingProviderMetadata": {
"zai": {

View File

@@ -192,7 +192,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10430,
10431,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(

View File

@@ -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(

View File

@@ -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, {

View File

@@ -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 } : {}),
};
}

View File

@@ -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<string, string> | 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();
});
});

View File

@@ -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 } : {}),
};
}

View File

@@ -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 () => {

View File

@@ -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" } : {}),
};

View File

@@ -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,
};

View File

@@ -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({

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 }) => {

View File

@@ -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<UsageProviderId, string> = {
export const PROVIDER_LABELS: Readonly<Record<string, string>> = {
anthropic: "Claude",
clawrouter: "ClawRouter",
deepseek: "DeepSeek",
@@ -14,30 +14,23 @@ export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
"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([

View File

@@ -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,

View File

@@ -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;

View File

@@ -1,6 +1,7 @@
// Public usage fetch helpers for provider plugins.
export type {
ProviderUsageBilling,
ProviderUsageSnapshot,
UsageProviderId,
UsageWindow,

View File

@@ -19,6 +19,7 @@ const PROVIDER_CONTRIBUTION_CONTRACTS = [
"musicGenerationProviders",
"webFetchProviders",
"webSearchProviders",
"usageProviders",
] as const;
type OwnerMap = ReadonlyMap<string, readonly string[]>;

View File

@@ -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"],
});
});

View File

@@ -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",

View File

@@ -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 } : {}),

View File

@@ -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<IsPluginProvidersLoadInFlight>((
const resolveCatalogHookProviderPluginIdsMock = vi.fn<ResolveCatalogHookProviderPluginIds>(
(_) => [] as string[],
);
const resolveUsageHookProviderPluginContractsMock = vi.fn<ResolveUsageHookProviderPluginContracts>(
(_) => [],
);
const resolveExternalAuthProfileCompatFallbackPluginIdsMock =
vi.fn<ResolveExternalAuthProfileCompatFallbackPluginIds>((_) => [] 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,

View File

@@ -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<string, ProviderUsagePluginDescriptor>();
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;

View File

@@ -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({

View File

@@ -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 };

View File

@@ -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,
});
});
});

View File

@@ -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<string, ProviderUsageBilling>();
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,

View File

@@ -476,7 +476,9 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
if (
usageEntry &&
!usageEntry.error &&
(usageEntry.windows.length > 0 || Boolean(usageEntry.summary?.trim()))
(usageEntry.windows.length > 0 ||
Boolean(usageEntry.billing?.length) ||
Boolean(usageEntry.summary?.trim()))
) {
const summaryLine = formatUsageWindowSummary(usageEntry, {
now: Date.now(),

View File

@@ -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"),
});
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1118,6 +1118,15 @@ export const ar: TranslationMap = {
session: "جلسة",
sessions: "جلسات",
},
providerUsage: {
title: "خطط المزوّدين والفوترة",
subtitle: "بيانات مباشرة للخطة والحصة والرصيد والميزانية من المزوّدين الذين تم إعدادهم.",
balance: "الرصيد",
spend: "الاستخدام",
budget: "الميزانية",
remaining: "متبقٍ {percent}%",
resets: "تتم إعادة التعيين في {date}",
},
presets: {
today: "اليوم",
last7d: "7 أيام",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1126,6 +1126,15 @@ export const fa: TranslationMap = {
session: "نشست",
sessions: "نشست‌ها",
},
providerUsage: {
title: "طرح‌ها و صورت‌حساب ارائه‌دهندگان",
subtitle: "داده‌های زندهٔ طرح، سهمیه، موجودی و بودجه که ارائه‌دهندگان پیکربندی‌شده گزارش می‌کنند.",
balance: "موجودی",
spend: "مصرف",
budget: "بودجه",
remaining: "{percent}٪ باقی‌مانده",
resets: "بازنشانی در {date}",
},
presets: {
today: "امروز",
last7d: "۷روز",

View File

@@ -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: "Aujourdhui",
last7d: "7 j",

View File

@@ -1122,6 +1122,15 @@ export const hi: TranslationMap = {
session: "सेशन",
sessions: "सेशन",
},
providerUsage: {
title: "प्रदाता प्लान और बिलिंग",
subtitle: "कॉन्फ़िगर किए गए प्रदाताओं से प्राप्त प्लान, कोटा, शेष राशि और बजट का लाइव डेटा।",
balance: "शेष राशि",
spend: "उपयोग",
budget: "बजट",
remaining: "{percent}% शेष",
resets: "{date} को रीसेट",
},
presets: {
today: "आज",
last7d: "7d",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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日",

View File

@@ -1122,6 +1122,15 @@ export const ko: TranslationMap = {
session: "세션",
sessions: "세션",
},
providerUsage: {
title: "제공업체 요금제 및 결제",
subtitle: "설정된 제공업체에서 보고한 요금제, 할당량, 잔액 및 예산의 실시간 데이터입니다.",
balance: "잔액",
spend: "사용량",
budget: "예산",
remaining: "{percent}% 남음",
resets: "{date}에 초기화",
},
presets: {
today: "오늘",
last7d: "7일",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1133,6 +1133,16 @@ export const ru: TranslationMap = {
session: "сеанс",
sessions: "сеансы",
},
providerUsage: {
title: "Тарифы и расчёты провайдеров",
subtitle:
"Актуальные данные о тарифах, квотах, балансе и бюджете от настроенных провайдеров.",
balance: "Баланс",
spend: "Использование",
budget: "Бюджет",
remaining: "Осталось {percent}%",
resets: "Сброс {date}",
},
presets: {
today: "Сегодня",
last7d: "7 дн.",

View File

@@ -1114,6 +1114,15 @@ export const th: TranslationMap = {
session: "เซสชัน",
sessions: "เซสชัน",
},
providerUsage: {
title: "แพ็กเกจและการเรียกเก็บเงินของผู้ให้บริการ",
subtitle: "ข้อมูลแพ็กเกจ โควตา ยอดคงเหลือ และงบประมาณแบบเรียลไทม์ที่รายงานโดยผู้ให้บริการที่กำหนดค่าไว้",
balance: "ยอดคงเหลือ",
spend: "การใช้งาน",
budget: "งบประมาณ",
remaining: "เหลือ {percent}%",
resets: "รีเซ็ต {date}",
},
presets: {
today: "วันนี้",
last7d: "7 วัน",

View File

@@ -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",

View File

@@ -1131,6 +1131,15 @@ export const uk: TranslationMap = {
session: "сеанс",
sessions: "сеанси",
},
providerUsage: {
title: "Плани провайдерів і оплата",
subtitle: "Актуальні дані про план, квоту, баланс і бюджет від налаштованих провайдерів.",
balance: "Баланс",
spend: "Використання",
budget: "Бюджет",
remaining: "Залишилося {percent}%",
resets: "Скидання {date}",
},
presets: {
today: "Сьогодні",
last7d: "7 дн.",

View File

@@ -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",

View File

@@ -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天",

View File

@@ -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 天",

View File

@@ -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 };

View File

@@ -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<UsageRou
query,
result: null,
costSummary: null,
providerUsageSummary: null,
error: null,
};
}
try {
const [result, costSummary] = await Promise.all([
const [result, costSummary, providerUsageSummary] = await Promise.all([
requestSessionUsage(gateway.client, {
...query,
agentId: query.agentId ?? undefined,
@@ -57,6 +59,7 @@ async function loadUsageRouteData(context: ApplicationContext): Promise<UsageRou
agentScope: "all",
...buildSessionUsageDateParams(query.timeZone),
}),
gateway.client.request<ProviderUsageSummary>("usage.status").catch(() => null),
]);
return {
client: gateway.client,
@@ -64,6 +67,7 @@ async function loadUsageRouteData(context: ApplicationContext): Promise<UsageRou
query,
result,
costSummary,
providerUsageSummary,
error: null,
};
} catch (error) {
@@ -73,6 +77,7 @@ async function loadUsageRouteData(context: ApplicationContext): Promise<UsageRou
query,
result: null,
costSummary: null,
providerUsageSummary: null,
error: errorMessage(error),
};
}

View File

@@ -1,6 +1,7 @@
// Control UI view renders usageTypes screen content.
import type {
CostUsageDailyEntry,
ProviderUsageSummary,
SessionsUsageEntry,
SessionsUsageResult,
SessionsUsageTotals,
@@ -34,6 +35,7 @@ export type UsageDataState = {
aggregates: UsageAggregates | null;
costDaily: CostDailyEntry[];
cacheStatus: SessionsUsageResult["cacheStatus"];
providerUsage: ProviderUsageSummary["providers"];
};
export type UsageFilterState = {

Some files were not shown because too many files have changed in this diff Show More