mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-13 17:07:40 +00:00
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:
committed by
GitHub
parent
a4261cac4d
commit
07e2d633cc
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -243,7 +243,8 @@
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["anthropic"]
|
||||
"mediaUnderstandingProviders": ["anthropic"],
|
||||
"usageProviders": ["anthropic"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"anthropic": {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["clawrouter"],
|
||||
"contracts": {
|
||||
"usageProviders": ["clawrouter"]
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"enabledByDefault": true,
|
||||
"providerCatalogEntry": "./provider-discovery.ts",
|
||||
"providers": ["deepseek"],
|
||||
"contracts": {
|
||||
"usageProviders": ["deepseek"]
|
||||
},
|
||||
"providerEndpoints": [
|
||||
{
|
||||
"endpointClass": "deepseek-native",
|
||||
|
||||
@@ -153,7 +153,8 @@
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["github-copilot"]
|
||||
"memoryEmbeddingProviders": ["github-copilot"],
|
||||
"usageProviders": ["github-copilot"]
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
|
||||
@@ -674,7 +674,8 @@
|
||||
"realtimeVoiceProviders": ["google"],
|
||||
"speechProviders": ["google"],
|
||||
"videoGenerationProviders": ["google"],
|
||||
"webSearchProviders": ["gemini"]
|
||||
"webSearchProviders": ["gemini"],
|
||||
"usageProviders": ["google-gemini-cli"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"google": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -326,7 +326,8 @@
|
||||
"memoryEmbeddingProviders": ["openai"],
|
||||
"mediaUnderstandingProviders": ["openai"],
|
||||
"imageGenerationProviders": ["openai"],
|
||||
"videoGenerationProviders": ["openai"]
|
||||
"videoGenerationProviders": ["openai"],
|
||||
"usageProviders": ["openai"]
|
||||
},
|
||||
"imageGenerationProviderMetadata": {
|
||||
"openai": {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -77,7 +77,8 @@
|
||||
"imageGenerationProviders": ["openrouter"],
|
||||
"musicGenerationProviders": ["openrouter"],
|
||||
"videoGenerationProviders": ["openrouter"],
|
||||
"speechProviders": ["openrouter"]
|
||||
"speechProviders": ["openrouter"],
|
||||
"usageProviders": ["openrouter"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"openrouter": {
|
||||
|
||||
233
extensions/openrouter/usage.test.ts
Normal file
233
extensions/openrouter/usage.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
219
extensions/openrouter/usage.ts
Normal file
219
extensions/openrouter/usage.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["venice"],
|
||||
"contracts": {
|
||||
"usageProviders": ["venice"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "venice",
|
||||
|
||||
95
extensions/venice/usage.test.ts
Normal file
95
extensions/venice/usage.test.ts
Normal 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
128
extensions/venice/usage.ts
Normal 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" } : {}),
|
||||
};
|
||||
}
|
||||
@@ -128,7 +128,8 @@
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"speechProviders": ["xiaomi"]
|
||||
"speechProviders": ["xiaomi"],
|
||||
"usageProviders": ["xiaomi", "xiaomi-token-plan"]
|
||||
},
|
||||
"providerAuthEnvVars": {
|
||||
"xiaomi": ["XIAOMI_API_KEY"],
|
||||
|
||||
@@ -328,7 +328,8 @@
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"mediaUnderstandingProviders": ["zai"]
|
||||
"mediaUnderstandingProviders": ["zai"],
|
||||
"usageProviders": ["zai"]
|
||||
},
|
||||
"mediaUnderstandingProviderMetadata": {
|
||||
"zai": {
|
||||
|
||||
@@ -192,7 +192,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
|
||||
),
|
||||
publicExports: readPluginSdkSurfaceBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
|
||||
10430,
|
||||
10431,
|
||||
env,
|
||||
),
|
||||
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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" } : {}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Public usage fetch helpers for provider plugins.
|
||||
|
||||
export type {
|
||||
ProviderUsageBilling,
|
||||
ProviderUsageSnapshot,
|
||||
UsageProviderId,
|
||||
UsageWindow,
|
||||
|
||||
@@ -19,6 +19,7 @@ const PROVIDER_CONTRIBUTION_CONTRACTS = [
|
||||
"musicGenerationProviders",
|
||||
"webFetchProviders",
|
||||
"webSearchProviders",
|
||||
"usageProviders",
|
||||
] as const;
|
||||
|
||||
type OwnerMap = ReadonlyMap<string, readonly string[]>;
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
78
src/status/codex-synthetic-usage.test.ts
Normal file
78
src/status/codex-synthetic-usage.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/ar.meta.json
generated
8
ui/src/i18n/.i18n/ar.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/de.meta.json
generated
8
ui/src/i18n/.i18n/de.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/es.meta.json
generated
8
ui/src/i18n/.i18n/es.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/fa.meta.json
generated
8
ui/src/i18n/.i18n/fa.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/fr.meta.json
generated
8
ui/src/i18n/.i18n/fr.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
12
ui/src/i18n/.i18n/hi.meta.json
generated
12
ui/src/i18n/.i18n/hi.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
1
ui/src/i18n/.i18n/hi.tm.jsonl
generated
1
ui/src/i18n/.i18n/hi.tm.jsonl
generated
@@ -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"}
|
||||
|
||||
8
ui/src/i18n/.i18n/id.meta.json
generated
8
ui/src/i18n/.i18n/id.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/it.meta.json
generated
8
ui/src/i18n/.i18n/it.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/ja-JP.meta.json
generated
8
ui/src/i18n/.i18n/ja-JP.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/ko.meta.json
generated
8
ui/src/i18n/.i18n/ko.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/nl.meta.json
generated
8
ui/src/i18n/.i18n/nl.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/pl.meta.json
generated
8
ui/src/i18n/.i18n/pl.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/pt-BR.meta.json
generated
8
ui/src/i18n/.i18n/pt-BR.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
12
ui/src/i18n/.i18n/ru.meta.json
generated
12
ui/src/i18n/.i18n/ru.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
1
ui/src/i18n/.i18n/ru.tm.jsonl
generated
1
ui/src/i18n/.i18n/ru.tm.jsonl
generated
@@ -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"}
|
||||
|
||||
8
ui/src/i18n/.i18n/th.meta.json
generated
8
ui/src/i18n/.i18n/th.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/tr.meta.json
generated
8
ui/src/i18n/.i18n/tr.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/uk.meta.json
generated
8
ui/src/i18n/.i18n/uk.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/vi.meta.json
generated
8
ui/src/i18n/.i18n/vi.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/zh-CN.meta.json
generated
8
ui/src/i18n/.i18n/zh-CN.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
8
ui/src/i18n/.i18n/zh-TW.meta.json
generated
8
ui/src/i18n/.i18n/zh-TW.meta.json
generated
@@ -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
|
||||
}
|
||||
|
||||
9
ui/src/i18n/locales/ar.ts
generated
9
ui/src/i18n/locales/ar.ts
generated
@@ -1118,6 +1118,15 @@ export const ar: TranslationMap = {
|
||||
session: "جلسة",
|
||||
sessions: "جلسات",
|
||||
},
|
||||
providerUsage: {
|
||||
title: "خطط المزوّدين والفوترة",
|
||||
subtitle: "بيانات مباشرة للخطة والحصة والرصيد والميزانية من المزوّدين الذين تم إعدادهم.",
|
||||
balance: "الرصيد",
|
||||
spend: "الاستخدام",
|
||||
budget: "الميزانية",
|
||||
remaining: "متبقٍ {percent}%",
|
||||
resets: "تتم إعادة التعيين في {date}",
|
||||
},
|
||||
presets: {
|
||||
today: "اليوم",
|
||||
last7d: "7 أيام",
|
||||
|
||||
10
ui/src/i18n/locales/de.ts
generated
10
ui/src/i18n/locales/de.ts
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
10
ui/src/i18n/locales/es.ts
generated
10
ui/src/i18n/locales/es.ts
generated
@@ -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",
|
||||
|
||||
9
ui/src/i18n/locales/fa.ts
generated
9
ui/src/i18n/locales/fa.ts
generated
@@ -1126,6 +1126,15 @@ export const fa: TranslationMap = {
|
||||
session: "نشست",
|
||||
sessions: "نشستها",
|
||||
},
|
||||
providerUsage: {
|
||||
title: "طرحها و صورتحساب ارائهدهندگان",
|
||||
subtitle: "دادههای زندهٔ طرح، سهمیه، موجودی و بودجه که ارائهدهندگان پیکربندیشده گزارش میکنند.",
|
||||
balance: "موجودی",
|
||||
spend: "مصرف",
|
||||
budget: "بودجه",
|
||||
remaining: "{percent}٪ باقیمانده",
|
||||
resets: "بازنشانی در {date}",
|
||||
},
|
||||
presets: {
|
||||
today: "امروز",
|
||||
last7d: "۷روز",
|
||||
|
||||
10
ui/src/i18n/locales/fr.ts
generated
10
ui/src/i18n/locales/fr.ts
generated
@@ -1133,6 +1133,16 @@ export const fr: TranslationMap = {
|
||||
session: "session",
|
||||
sessions: "sessions",
|
||||
},
|
||||
providerUsage: {
|
||||
title: "Forfaits et facturation des fournisseurs",
|
||||
subtitle:
|
||||
"Données en temps réel sur les forfaits, quotas, soldes et budgets communiquées par les fournisseurs configurés.",
|
||||
balance: "Solde",
|
||||
spend: "Utilisation",
|
||||
budget: "Budget",
|
||||
remaining: "{percent}% restant",
|
||||
resets: "Réinitialisation le {date}",
|
||||
},
|
||||
presets: {
|
||||
today: "Aujourd’hui",
|
||||
last7d: "7 j",
|
||||
|
||||
9
ui/src/i18n/locales/hi.ts
generated
9
ui/src/i18n/locales/hi.ts
generated
@@ -1122,6 +1122,15 @@ export const hi: TranslationMap = {
|
||||
session: "सेशन",
|
||||
sessions: "सेशन",
|
||||
},
|
||||
providerUsage: {
|
||||
title: "प्रदाता प्लान और बिलिंग",
|
||||
subtitle: "कॉन्फ़िगर किए गए प्रदाताओं से प्राप्त प्लान, कोटा, शेष राशि और बजट का लाइव डेटा।",
|
||||
balance: "शेष राशि",
|
||||
spend: "उपयोग",
|
||||
budget: "बजट",
|
||||
remaining: "{percent}% शेष",
|
||||
resets: "{date} को रीसेट",
|
||||
},
|
||||
presets: {
|
||||
today: "आज",
|
||||
last7d: "7d",
|
||||
|
||||
10
ui/src/i18n/locales/id.ts
generated
10
ui/src/i18n/locales/id.ts
generated
@@ -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",
|
||||
|
||||
10
ui/src/i18n/locales/it.ts
generated
10
ui/src/i18n/locales/it.ts
generated
@@ -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",
|
||||
|
||||
10
ui/src/i18n/locales/ja-JP.ts
generated
10
ui/src/i18n/locales/ja-JP.ts
generated
@@ -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日",
|
||||
|
||||
9
ui/src/i18n/locales/ko.ts
generated
9
ui/src/i18n/locales/ko.ts
generated
@@ -1122,6 +1122,15 @@ export const ko: TranslationMap = {
|
||||
session: "세션",
|
||||
sessions: "세션",
|
||||
},
|
||||
providerUsage: {
|
||||
title: "제공업체 요금제 및 결제",
|
||||
subtitle: "설정된 제공업체에서 보고한 요금제, 할당량, 잔액 및 예산의 실시간 데이터입니다.",
|
||||
balance: "잔액",
|
||||
spend: "사용량",
|
||||
budget: "예산",
|
||||
remaining: "{percent}% 남음",
|
||||
resets: "{date}에 초기화",
|
||||
},
|
||||
presets: {
|
||||
today: "오늘",
|
||||
last7d: "7일",
|
||||
|
||||
10
ui/src/i18n/locales/nl.ts
generated
10
ui/src/i18n/locales/nl.ts
generated
@@ -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",
|
||||
|
||||
10
ui/src/i18n/locales/pl.ts
generated
10
ui/src/i18n/locales/pl.ts
generated
@@ -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",
|
||||
|
||||
10
ui/src/i18n/locales/pt-BR.ts
generated
10
ui/src/i18n/locales/pt-BR.ts
generated
@@ -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",
|
||||
|
||||
10
ui/src/i18n/locales/ru.ts
generated
10
ui/src/i18n/locales/ru.ts
generated
@@ -1133,6 +1133,16 @@ export const ru: TranslationMap = {
|
||||
session: "сеанс",
|
||||
sessions: "сеансы",
|
||||
},
|
||||
providerUsage: {
|
||||
title: "Тарифы и расчёты провайдеров",
|
||||
subtitle:
|
||||
"Актуальные данные о тарифах, квотах, балансе и бюджете от настроенных провайдеров.",
|
||||
balance: "Баланс",
|
||||
spend: "Использование",
|
||||
budget: "Бюджет",
|
||||
remaining: "Осталось {percent}%",
|
||||
resets: "Сброс {date}",
|
||||
},
|
||||
presets: {
|
||||
today: "Сегодня",
|
||||
last7d: "7 дн.",
|
||||
|
||||
9
ui/src/i18n/locales/th.ts
generated
9
ui/src/i18n/locales/th.ts
generated
@@ -1114,6 +1114,15 @@ export const th: TranslationMap = {
|
||||
session: "เซสชัน",
|
||||
sessions: "เซสชัน",
|
||||
},
|
||||
providerUsage: {
|
||||
title: "แพ็กเกจและการเรียกเก็บเงินของผู้ให้บริการ",
|
||||
subtitle: "ข้อมูลแพ็กเกจ โควตา ยอดคงเหลือ และงบประมาณแบบเรียลไทม์ที่รายงานโดยผู้ให้บริการที่กำหนดค่าไว้",
|
||||
balance: "ยอดคงเหลือ",
|
||||
spend: "การใช้งาน",
|
||||
budget: "งบประมาณ",
|
||||
remaining: "เหลือ {percent}%",
|
||||
resets: "รีเซ็ต {date}",
|
||||
},
|
||||
presets: {
|
||||
today: "วันนี้",
|
||||
last7d: "7 วัน",
|
||||
|
||||
10
ui/src/i18n/locales/tr.ts
generated
10
ui/src/i18n/locales/tr.ts
generated
@@ -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",
|
||||
|
||||
9
ui/src/i18n/locales/uk.ts
generated
9
ui/src/i18n/locales/uk.ts
generated
@@ -1131,6 +1131,15 @@ export const uk: TranslationMap = {
|
||||
session: "сеанс",
|
||||
sessions: "сеанси",
|
||||
},
|
||||
providerUsage: {
|
||||
title: "Плани провайдерів і оплата",
|
||||
subtitle: "Актуальні дані про план, квоту, баланс і бюджет від налаштованих провайдерів.",
|
||||
balance: "Баланс",
|
||||
spend: "Використання",
|
||||
budget: "Бюджет",
|
||||
remaining: "Залишилося {percent}%",
|
||||
resets: "Скидання {date}",
|
||||
},
|
||||
presets: {
|
||||
today: "Сьогодні",
|
||||
last7d: "7 дн.",
|
||||
|
||||
10
ui/src/i18n/locales/vi.ts
generated
10
ui/src/i18n/locales/vi.ts
generated
@@ -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",
|
||||
|
||||
9
ui/src/i18n/locales/zh-CN.ts
generated
9
ui/src/i18n/locales/zh-CN.ts
generated
@@ -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天",
|
||||
|
||||
9
ui/src/i18n/locales/zh-TW.ts
generated
9
ui/src/i18n/locales/zh-TW.ts
generated
@@ -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 天",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user