From d446c26acb1ec6653ac81673265a75acd7c839ae Mon Sep 17 00:00:00 2001 From: litang9 <141409885+litang9@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:35:41 +0800 Subject: [PATCH] feat(deepseek): show provider balance in usage status Show DeepSeek API-key account balance in status/auth-status usage surfaces by adding a summary-only provider usage snapshot path, a DeepSeek balance fetcher, SDK/docs coverage, and focused regression tests. Maintainer verification accepted the additive provider-usage/status contract and the DeepSeek balance visibility boundary for authenticated status surfaces. Proof: - Live DeepSeek balance proof via 1Password-backed DEEPSEEK_API_KEY against https://api.deepseek.com/user/balance; key and balance amount redacted. - GitHub CI run 26717953383 passed on the current head. - Real behavior proof run 26718215605 passed after the PR body was refreshed. - Local clean PR clone: git diff --check; node --max-old-space-size=8192 --import tsx scripts/generate-plugin-sdk-api-baseline.ts --check; node scripts/run-vitest.mjs run src/agents/bash-tools.exec.path.test.ts. Co-authored-by: Alex Tang Co-authored-by: litang9 <141409885+litang9@users.noreply.github.com> --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/concepts/usage-tracking.md | 14 ++- docs/plugins/sdk-subpaths.md | 9 +- extensions/deepseek/index.test.ts | 38 ++++++ extensions/deepseek/index.ts | 9 ++ src/agents/auth-profiles.store.save.test.ts | 1 + src/agents/bash-tools.exec.path.test.ts | 5 +- src/auto-reply/reply/commands-status.test.ts | 48 ++++++++ .../server-methods/models-auth-status.test.ts | 66 ++++++++++- .../server-methods/models-auth-status.ts | 47 ++++++-- .../server-startup-post-attach.test.ts | 4 +- .../server.chat.gateway-server-chat.test.ts | 12 +- .../provider-usage.fetch.deepseek.test.ts | 94 +++++++++++++++ src/infra/provider-usage.fetch.deepseek.ts | 108 ++++++++++++++++++ src/infra/provider-usage.fetch.ts | 1 + src/infra/provider-usage.format.test.ts | 38 ++++++ src/infra/provider-usage.format.ts | 12 +- src/infra/provider-usage.load.test.ts | 27 +++++ src/infra/provider-usage.load.ts | 3 + src/infra/provider-usage.shared.test.ts | 1 + src/infra/provider-usage.shared.ts | 2 + src/infra/provider-usage.test.ts | 29 ++++- src/infra/provider-usage.types.ts | 2 + src/plugin-sdk/provider-usage.ts | 1 + ...in-sdk-package-contract-guardrails.test.ts | 6 +- src/status/status-text.ts | 6 +- ui/src/ui/control-ui-vite-config.node.test.ts | 3 +- 27 files changed, 557 insertions(+), 33 deletions(-) create mode 100644 src/infra/provider-usage.fetch.deepseek.test.ts create mode 100644 src/infra/provider-usage.fetch.deepseek.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index d8f24bff37be..16c065f34214 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -80275837fdd0ce5d0cbb73045f304e2cb7befa59c99ddf00c8ec9b85056caf3f plugin-sdk-api-baseline.json -95ef2551f6093170bc32b51b2782bd570d86444f576cf7e168196499b8c35e36 plugin-sdk-api-baseline.jsonl +5a9934e0224ac39541bec1d69c350d490874a7c259f464f19a5f5adf46243820 plugin-sdk-api-baseline.json +60dd5b951ec3ce5520d98bdf0bfbfa27ec00f56cde8bb95edc5a6f89149c044a plugin-sdk-api-baseline.jsonl diff --git a/docs/concepts/usage-tracking.md b/docs/concepts/usage-tracking.md index 4beb4b1f0abc..72bb60e12c5c 100644 --- a/docs/concepts/usage-tracking.md +++ b/docs/concepts/usage-tracking.md @@ -9,9 +9,12 @@ title: "Usage tracking" ## What it is - Pulls provider usage/quota directly from their usage endpoints. -- No estimated costs; only the provider-reported windows. -- Human-readable status output is normalized to `X% left`, even when an - upstream API reports consumed quota, remaining quota, or only raw counts. +- No estimated costs; only provider-reported quota windows or account-state + summaries. +- Human-readable quota-window status output is normalized to `X% left`, even + when an upstream API reports consumed quota, remaining quota, or only raw + counts. Providers without resettable quota windows can show provider summary + text instead, such as a balance. - Session-level `/status` and `session_status` can fall back to the latest transcript usage entry when the live session snapshot is sparse. That fallback fills missing token/cache counters, can recover the active runtime @@ -20,7 +23,7 @@ title: "Usage tracking" ## Where it shows up -- `/status` in chats: emoji-rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available as a normalized `X% left` window. +- `/status` in chats: emoji-rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available as a normalized `X% left` window or provider summary text. - `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only). - `/usage cost` in chats: local cost summary aggregated from OpenClaw session logs. - CLI: `openclaw status --usage` prints a full per-provider breakdown. @@ -53,6 +56,9 @@ title: "Usage tracking" name in the plan label. - **Xiaomi MiMo**: API key via env/config/auth store (`XIAOMI_API_KEY`). - **z.ai**: API key via env/config/auth store. +- **DeepSeek**: API key via env/config/auth store (`DEEPSEEK_API_KEY`). + OpenClaw calls DeepSeek's balance endpoint and shows the provider-reported + balance as text instead of a percent-left quota window. Usage is hidden when no usable provider usage auth can be resolved. Providers can supply plugin-specific usage auth logic; otherwise OpenClaw falls back to diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 8cd5de917457..cdeb5ce73835 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -160,7 +160,7 @@ and pairing-path families. | `plugin-sdk/provider-web-search` | Web-search provider registration/cache/runtime helpers | | `plugin-sdk/embedding-providers` | General embedding provider types and read helpers, including `EmbeddingProviderAdapter`, `getEmbeddingProvider(...)`, and `listEmbeddingProviders(...)`; plugins register providers through `api.registerEmbeddingProvider(...)` so manifest ownership is enforced | | `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics | - | `plugin-sdk/provider-usage` | `fetchClaudeUsage` and similar | + | `plugin-sdk/provider-usage` | Provider usage snapshot types, shared usage fetch helpers, and provider fetchers such as `fetchClaudeUsage` | | `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, plain-text tool-call compat, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers | | `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities | | `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams | @@ -169,6 +169,13 @@ and pairing-path families. | `plugin-sdk/group-activation` | Narrow group activation mode and command parsing helpers | +Provider usage snapshots normally report one or more quota `windows`, each with +a label, percent used, and optional reset time. Providers that expose balance or +account-state text instead of resettable quota windows should return +`summary` with an empty `windows` array rather than fabricating percentages. +OpenClaw displays that summary text in status output; use `error` only when the +usage endpoint failed or returned no usable usage data. + | Subpath | Key exports | | --- | --- | diff --git a/extensions/deepseek/index.test.ts b/extensions/deepseek/index.test.ts index 484092a34b5c..c3a9d78cec1a 100644 --- a/extensions/deepseek/index.test.ts +++ b/extensions/deepseek/index.test.ts @@ -5,6 +5,7 @@ import { resolveProviderPluginChoice, } from "openclaw/plugin-sdk/plugin-test-runtime"; import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime"; +import { createProviderUsageFetch, makeResponse } from "openclaw/plugin-sdk/test-env"; import { describe, expect, it } from "vitest"; import { runSingleProviderCatalog } from "../test-support/provider-model-test-helpers.js"; import deepseekPlugin from "./index.js"; @@ -212,6 +213,43 @@ describe("deepseek provider plugin", () => { ).toBe(true); }); + it("resolves API-key usage auth from DeepSeek config sources", async () => { + const provider = await registerSingleProviderPlugin(deepseekPlugin); + + await expect( + provider.resolveUsageAuth?.({ + env: {}, + resolveApiKeyFromConfigAndStore: (options?: { envDirect?: Array }) => { + expect(options?.envDirect).toEqual([undefined]); + return "config-deepseek-key"; + }, + } as never), + ).resolves.toEqual({ token: "config-deepseek-key" }); + }); + + it("fetches DeepSeek usage balance through the provider hook", async () => { + const provider = await registerSingleProviderPlugin(deepseekPlugin); + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + is_available: true, + balance_infos: [{ currency: "CNY", total_balance: "8.88", granted_balance: "1.00" }], + }), + ); + + await expect( + provider.fetchUsageSnapshot?.({ + token: "deepseek-key", + timeoutMs: 5000, + fetchFn: mockFetch, + } as never), + ).resolves.toMatchObject({ + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥8.88 · Granted ¥1.00", + }); + }); + it("owns OpenAI-compatible replay policy", async () => { const provider = await registerSingleProviderPlugin(deepseekPlugin); diff --git a/extensions/deepseek/index.ts b/extensions/deepseek/index.ts index 26fa1e4fb6d3..04d11dc44a85 100644 --- a/extensions/deepseek/index.ts +++ b/extensions/deepseek/index.ts @@ -2,6 +2,7 @@ import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provid import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; +import { fetchDeepSeekUsage } from "openclaw/plugin-sdk/provider-usage"; import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildDeepSeekProvider } from "./provider-catalog.js"; import { createDeepSeekV4ThinkingWrapper } from "./stream.js"; @@ -54,5 +55,13 @@ export default defineSingleProviderPluginEntry({ wrapStreamFn: (ctx) => createDeepSeekV4ThinkingWrapper(ctx.streamFn, ctx.thinkingLevel), resolveThinkingProfile: ({ modelId }) => resolveDeepSeekV4ThinkingProfile(modelId), isModernModelRef: ({ modelId }) => Boolean(resolveDeepSeekV4ThinkingProfile(modelId)), + resolveUsageAuth: async (ctx) => { + const apiKey = ctx.resolveApiKeyFromConfigAndStore({ + envDirect: [ctx.env.DEEPSEEK_API_KEY], + }); + return apiKey ? { token: apiKey } : null; + }, + fetchUsageSnapshot: async (ctx) => + await fetchDeepSeekUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn), }, }); diff --git a/src/agents/auth-profiles.store.save.test.ts b/src/agents/auth-profiles.store.save.test.ts index 9cb745a3a7b8..11af013da982 100644 --- a/src/agents/auth-profiles.store.save.test.ts +++ b/src/agents/auth-profiles.store.save.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { legacyOAuthSidecarTestUtils } from "../commands/doctor/shared/legacy-oauth-sidecar.js"; import { resolveOAuthDir } from "../config/paths.js"; import { MAX_DATE_TIMESTAMP_MS } from "../shared/number-coercion.js"; import { resolveAuthStatePath, resolveAuthStorePath } from "./auth-profiles/paths.js"; diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 33883121ec5d..aac16a21e523 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -8,6 +8,7 @@ import { sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; const FOREGROUND_TEST_YIELD_MS = 120_000; +const ENV_KEYS = ["OPENCLAW_EXEC_SHELL_SNAPSHOT", "PATH", "SHELL", "SSLKEYLOGFILE"] as const; type GetShellPathFromLoginShell = typeof import("../infra/shell-env.js").getShellPathFromLoginShell; const shellEnvMocks = vi.hoisted(() => ({ getShellPathFromLoginShell: vi.fn(() => "/custom/bin:/opt/bin"), @@ -144,7 +145,7 @@ describe("exec PATH login shell merge", () => { }); beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_EXEC_SHELL_SNAPSHOT", "PATH", "SHELL"]); + envSnapshot = captureEnv([...ENV_KEYS]); process.env.OPENCLAW_EXEC_SHELL_SNAPSHOT = "0"; shellEnvMocks.getShellPathFromLoginShell.mockReset(); shellEnvMocks.getShellPathFromLoginShell.mockReturnValue("/custom/bin:/opt/bin"); @@ -293,7 +294,7 @@ describe("exec host env validation", () => { let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_EXEC_SHELL_SNAPSHOT"]); + envSnapshot = captureEnv([...ENV_KEYS]); process.env.OPENCLAW_EXEC_SHELL_SNAPSHOT = "0"; }); diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index 866d342b6cc7..e3db6993e5ae 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -830,6 +830,54 @@ describe("buildStatusReply subagent summary", () => { ); }); + it("shows DeepSeek balance summaries in /status output", async () => { + providerUsageMock.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: Date.now(), + providers: [ + { + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }, + ], + }); + + const text = await buildStatusText({ + cfg: baseCfg, + sessionEntry: { + sessionId: "sess-status-deepseek-usage", + updatedAt: 0, + }, + sessionKey: "agent:main:main", + parentSessionKey: "agent:main:main", + sessionScope: "per-sender", + statusChannel: "mobilechat", + provider: "deepseek", + model: "deepseek-v4-pro", + contextTokens: 1_000_000, + resolvedFastMode: false, + resolvedVerboseLevel: "off", + resolvedReasoningLevel: "off", + resolveDefaultThinkingLevel: async () => undefined, + isGroup: false, + defaultGroupActivation: () => "mention", + modelAuthOverride: "api-key", + activeModelAuthOverride: "api-key", + }); + + const normalized = normalizeTestText(text); + expect(normalized).toContain("Model: deepseek/deepseek-v4-pro"); + expect(normalized).toContain("Usage: Balance ¥42.50"); + const providerUsageCall = providerUsageMock.loadProviderUsageSummary.mock.calls.find( + ([params]) => params?.providers?.includes("deepseek"), + ); + if (!providerUsageCall) { + throw new Error("expected provider usage summary call for deepseek"); + } + expect(providerUsageCall[0]?.providers).toEqual(["deepseek"]); + }); + it("uses Codex OAuth auth labels for explicit OpenAI OpenClaw auth order", async () => { await withTempHome( async (dir) => { diff --git a/src/gateway/server-methods/models-auth-status.test.ts b/src/gateway/server-methods/models-auth-status.test.ts index 7cb4f4811f28..74a79235acb3 100644 --- a/src/gateway/server-methods/models-auth-status.test.ts +++ b/src/gateway/server-methods/models-auth-status.test.ts @@ -1,9 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthHealthSummary } from "../../agents/auth-health.js"; import type { AuthProfileStore } from "../../agents/auth-profiles.js"; +import type { UsageSummary } from "../../infra/provider-usage.types.js"; import { MAX_DATE_TIMESTAMP_MS } from "../../shared/number-coercion.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; +const emptyUsageSummary = (): UsageSummary => ({ updatedAt: 0, providers: [] }); + const mocks = vi.hoisted(() => ({ getRuntimeConfig: vi.fn(() => ({})), resolveDefaultAgentDir: vi.fn(() => "/tmp/agent"), @@ -29,7 +32,7 @@ const mocks = vi.hoisted(() => ({ buildAuthHealthSummary: vi.fn( (): AuthHealthSummary => ({ now: 0, warnAfterMs: 0, profiles: [], providers: [] }), ), - loadProviderUsageSummary: vi.fn(async () => ({ updatedAt: 0, providers: [] })), + loadProviderUsageSummary: vi.fn(async (): Promise => emptyUsageSummary()), })); vi.mock("../../config/config.js", () => ({ @@ -216,7 +219,7 @@ describe("models.authStatus", () => { profiles: [], providers: [], }); - mocks.loadProviderUsageSummary.mockResolvedValue({ updatedAt: 0, providers: [] }); + mocks.loadProviderUsageSummary.mockResolvedValue(emptyUsageSummary()); }); it("returns a serialisable snapshot on first call", async () => { @@ -303,6 +306,65 @@ describe("models.authStatus", () => { expect(mocks.loadProviderUsageSummary).not.toHaveBeenCalled(); }); + it("adds DeepSeek API-key balance summaries to auth status usage", async () => { + mocks.buildAuthHealthSummary.mockReturnValue({ + now: 0, + warnAfterMs: 0, + profiles: [ + { + profileId: "deepseek:default", + provider: "deepseek", + type: "api_key", + status: "static", + source: "store", + label: "deepseek:default", + }, + ], + providers: [ + { + provider: "deepseek", + status: "static", + profiles: [ + { + profileId: "deepseek:default", + provider: "deepseek", + type: "api_key", + status: "static", + source: "store", + label: "deepseek:default", + }, + ], + }, + ], + }); + mocks.loadProviderUsageSummary.mockResolvedValue({ + updatedAt: 0, + providers: [ + { + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }, + ], + }); + + const opts = createOptions(); + await handler(opts); + + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({ + providers: ["deepseek"], + agentDir: "/tmp/agent", + timeoutMs: 3500, + }); + const [, payload] = firstRespondCall(opts) ?? []; + const result = payload as ModelAuthStatusResult; + expect(result.providers[0]?.usage).toEqual({ + windows: [], + summary: "Balance ¥42.50", + }); + }); + it("scopes external CLI auth overlays to configured providers", async () => { mocks.getRuntimeConfig.mockReturnValue({ auth: { diff --git a/src/gateway/server-methods/models-auth-status.ts b/src/gateway/server-methods/models-auth-status.ts index cc9583942720..eda713ffa318 100644 --- a/src/gateway/server-methods/models-auth-status.ts +++ b/src/gateway/server-methods/models-auth-status.ts @@ -26,7 +26,11 @@ import type { OpenClawConfig } from "../../config/config.js"; import { isSecretRef } from "../../config/types.secrets.js"; import { loadProviderUsageSummary } from "../../infra/provider-usage.load.js"; import { PROVIDER_LABELS, resolveUsageProviderId } from "../../infra/provider-usage.shared.js"; -import type { UsageProviderId, UsageWindow } from "../../infra/provider-usage.types.js"; +import type { + ProviderUsageSnapshot, + UsageProviderId, + UsageWindow, +} from "../../infra/provider-usage.types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { refreshActiveSecretsRuntimeSnapshot } from "../../secrets/runtime.js"; import { asDateTimestampMs } from "../../shared/number-coercion.js"; @@ -35,6 +39,9 @@ import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; const log = createSubsystemLogger("models-auth-status"); +const apiKeyUsageStatusProviders = new Set(["deepseek"]); + +type ProviderUsageStatus = Pick; /** * Models-auth status wire types. Mirrored in ui/src/ui/types.ts via an @@ -67,6 +74,7 @@ export type ModelAuthStatusProvider = { profiles: ModelAuthStatusProfile[]; usage?: { windows: UsageWindow[]; + summary?: string; plan?: string; }; }; @@ -238,12 +246,12 @@ export function aggregateOAuthStatus( function mapProvider( prov: AuthProviderHealth, - usageByProvider: Map, + usageByProvider: Map, expectsOAuthSet: Set, ): ModelAuthStatusProvider { - const usageProfile = prov.profiles.find( - (profile) => profile.type === "oauth" || profile.type === "token", - ); + const usageProfile = + prov.profiles.find((profile) => profile.type === "oauth" || profile.type === "token") ?? + prov.profiles.find((profile) => profile.type === "api_key"); const usageKey = resolveUsageProviderId(prov.provider, { credentialType: usageProfile?.type, }); @@ -260,7 +268,13 @@ function mapProvider( status: prof.status, expiry: buildExpiry(prof.remainingMs, prof.expiresAt), })), - usage: usage ? { windows: usage.windows, plan: usage.plan } : undefined, + usage: usage + ? { + windows: usage.windows, + ...(usage.summary ? { summary: usage.summary } : {}), + ...(usage.plan ? { plan: usage.plan } : {}), + } + : undefined, }; } @@ -437,17 +451,26 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { providers: configured.providers.length > 0 ? configured.providers : undefined, }); - // Usage queries only for refreshable credentials. + // Usage queries usually need refreshable credentials. Keep API-key status + // enrichment explicit so static auth providers are not polled by default. const usageProviderIds = [ ...new Set( authHealth.profiles - .filter((p) => p.type === "oauth" || p.type === "token") + .filter((p) => { + if (p.type === "oauth" || p.type === "token") { + return true; + } + const usageProvider = resolveUsageProviderId(p.provider, { + credentialType: p.type, + }); + return usageProvider ? apiKeyUsageStatusProviders.has(usageProvider) : false; + }) .map((p) => resolveUsageProviderId(p.provider, { credentialType: p.type })) .filter((id): id is UsageProviderId => Boolean(id)), ), ]; - const usageByProvider = new Map(); + const usageByProvider = new Map(); if (usageProviderIds.length > 0) { try { const usage = await loadProviderUsageSummary({ @@ -456,7 +479,11 @@ export const modelsAuthStatusHandlers: GatewayRequestHandlers = { timeoutMs: 3500, }); for (const snap of usage.providers) { - usageByProvider.set(snap.provider, { windows: snap.windows, plan: snap.plan }); + usageByProvider.set(snap.provider, { + windows: snap.windows, + ...(snap.summary ? { summary: snap.summary } : {}), + ...(snap.plan ? { plan: snap.plan } : {}), + }); } } catch (err) { // Usage data is auxiliary — failing here must not block auth status, diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index a6fbe665a134..d76738ae9a12 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -925,7 +925,9 @@ describe("startGatewayPostAttachRuntime", () => { onPostReadySidecars, onGatewayLifetimeSidecars, onSidecarsReady: () => { - setImmediate(postReadyRequestTurn); + setImmediate(() => { + postReadyRequestTurn(); + }); }, }); diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 8ba322d41ba2..23bab1997fb7 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -1375,7 +1375,11 @@ describe("gateway server chat", () => { expect(waitRes.ok).toBe(true); expect(waitRes.payload?.status).toBe("ok"); - const raw = await fs.readFile(testState.sessionStorePath!, "utf-8"); + const sessionStorePath = testState.sessionStorePath; + if (!sessionStorePath) { + throw new Error("session store path was not initialized"); + } + const raw = await fs.readFile(sessionStorePath, "utf-8"); const stored = JSON.parse(raw) as { "agent:main:main"?: { verboseLevel?: string; @@ -1416,7 +1420,11 @@ describe("gateway server chat", () => { expect(waitRes.ok).toBe(true); expect(waitRes.payload?.status).toBe("ok"); - const raw = await fs.readFile(testState.sessionStorePath!, "utf-8"); + const sessionStorePath = testState.sessionStorePath; + if (!sessionStorePath) { + throw new Error("session store path was not initialized"); + } + const raw = await fs.readFile(sessionStorePath, "utf-8"); const stored = JSON.parse(raw) as { "agent:main:main"?: { sessionId?: string; diff --git a/src/infra/provider-usage.fetch.deepseek.test.ts b/src/infra/provider-usage.fetch.deepseek.test.ts new file mode 100644 index 000000000000..5ab332d5e6fe --- /dev/null +++ b/src/infra/provider-usage.fetch.deepseek.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; +import { fetchDeepSeekUsage } from "./provider-usage.fetch.deepseek.js"; + +describe("fetchDeepSeekUsage", () => { + it("aggregates mixed-currency balance snapshots", async () => { + const mockFetch = createProviderUsageFetch(async (url, init) => { + const headers = (init?.headers as Record | undefined) ?? {}; + expect(url).toBe("https://api.deepseek.com/user/balance"); + expect(headers.Authorization).toBe("Bearer deepseek-key"); + expect(headers.Accept).toBe("application/json"); + return makeResponse(200, { + is_available: true, + balance_infos: [ + { + currency: "USD", + total_balance: "1.25", + granted_balance: "0", + topped_up_balance: "1.25", + }, + { + currency: "CNY", + total_balance: "42.50", + granted_balance: "12.00", + topped_up_balance: "30.50", + }, + ], + }); + }); + + const result = await fetchDeepSeekUsage("deepseek-key", 5000, mockFetch); + + expect(result).toEqual({ + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance $1.25 · Balance ¥42.50 · Granted ¥12.00 · Topped up ¥30.50", + }); + }); + + it("formats unknown currencies without assuming a symbol", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + balance_infos: [ + { + currency: "EUR", + total_balance: 3, + }, + ], + }), + ); + + const result = await fetchDeepSeekUsage("deepseek-key", 5000, mockFetch); + + expect(result.summary).toBe("Balance 3.00 EUR"); + }); + + it("returns HTTP errors for failed balance requests", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(401, { error: "invalid api key" }), + ); + + const result = await fetchDeepSeekUsage("deepseek-key", 5000, mockFetch); + + expect(result.error).toBe("HTTP 401"); + expect(result.windows).toHaveLength(0); + expect(result.summary).toBeUndefined(); + }); + + it("returns a stable error when balance data is absent", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { is_available: true, balance_infos: [] }), + ); + + const result = await fetchDeepSeekUsage("deepseek-key", 5000, mockFetch); + + expect(result.error).toBe("No balance data"); + expect(result.windows).toHaveLength(0); + }); + + it("marks unavailable accounts while keeping the balance summary", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + is_available: false, + balance_infos: [{ currency: "CNY", total_balance: "0" }], + }), + ); + + const result = await fetchDeepSeekUsage("deepseek-key", 5000, mockFetch); + + expect(result.summary).toBe("Balance ¥0.00"); + expect(result.plan).toBe("Unavailable"); + }); +}); diff --git a/src/infra/provider-usage.fetch.deepseek.ts b/src/infra/provider-usage.fetch.deepseek.ts new file mode 100644 index 000000000000..d1b818350c9a --- /dev/null +++ b/src/infra/provider-usage.fetch.deepseek.ts @@ -0,0 +1,108 @@ +import { + buildUsageHttpErrorSnapshot, + fetchJson, + parseFiniteNumber, + readUsageJson, +} from "./provider-usage.fetch.shared.js"; +import { PROVIDER_LABELS } from "./provider-usage.shared.js"; +import type { ProviderUsageSnapshot } from "./provider-usage.types.js"; + +type DeepSeekBalanceInfo = { + currency?: string; + total_balance?: string | number | null; + granted_balance?: string | number | null; + topped_up_balance?: string | number | null; +}; + +type DeepSeekBalanceResponse = { + is_available?: boolean; + balance_infos?: DeepSeekBalanceInfo[]; +}; + +const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance"; + +function formatCurrencyAmount(amount: number, currency?: string): string { + const normalized = currency?.trim().toUpperCase(); + if (normalized === "CNY" || normalized === "RMB") { + return `¥${amount.toFixed(2)}`; + } + if (normalized === "USD") { + return `$${amount.toFixed(2)}`; + } + return normalized ? `${amount.toFixed(2)} ${normalized}` : amount.toFixed(2); +} + +function parseBalanceAmount(value: unknown): number | undefined { + return parseFiniteNumber(value); +} + +function buildBalanceSummary(info: DeepSeekBalanceInfo): string | undefined { + const total = parseBalanceAmount(info.total_balance); + if (total === undefined) { + return undefined; + } + const granted = parseBalanceAmount(info.granted_balance); + const toppedUp = parseBalanceAmount(info.topped_up_balance); + const parts = [`Balance ${formatCurrencyAmount(total, info.currency)}`]; + if (granted !== undefined && granted > 0) { + parts.push(`Granted ${formatCurrencyAmount(granted, info.currency)}`); + } + if (toppedUp !== undefined && toppedUp > 0 && toppedUp !== total) { + parts.push(`Topped up ${formatCurrencyAmount(toppedUp, info.currency)}`); + } + return parts.join(" · "); +} + +export async function fetchDeepSeekUsage( + apiKey: string, + timeoutMs: number, + fetchFn: typeof fetch, +): Promise { + const res = await fetchJson( + DEEPSEEK_BALANCE_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, + timeoutMs, + fetchFn, + ); + + if (!res.ok) { + return buildUsageHttpErrorSnapshot({ + provider: "deepseek", + status: res.status, + }); + } + + const parsed = await readUsageJson("deepseek", res); + if (!parsed.ok) { + return parsed.snapshot; + } + + const data = parsed.data as DeepSeekBalanceResponse; + const balances = Array.isArray(data.balance_infos) ? data.balance_infos : []; + const summary = balances + .map((info) => buildBalanceSummary(info)) + .filter((entry): entry is string => Boolean(entry)) + .join(" · "); + if (!summary) { + return { + provider: "deepseek", + displayName: PROVIDER_LABELS.deepseek, + windows: [], + error: "No balance data", + }; + } + + return { + provider: "deepseek", + displayName: PROVIDER_LABELS.deepseek, + windows: [], + summary, + ...(data.is_available === false ? { plan: "Unavailable" } : {}), + }; +} diff --git a/src/infra/provider-usage.fetch.ts b/src/infra/provider-usage.fetch.ts index 87f216eef24b..7a68604b12fc 100644 --- a/src/infra/provider-usage.fetch.ts +++ b/src/infra/provider-usage.fetch.ts @@ -1,5 +1,6 @@ export { fetchClaudeUsage } from "./provider-usage.fetch.claude.js"; export { fetchCodexUsage } from "./provider-usage.fetch.codex.js"; +export { fetchDeepSeekUsage } from "./provider-usage.fetch.deepseek.js"; export { fetchGeminiUsage } from "./provider-usage.fetch.gemini.js"; export { fetchMinimaxUsage } from "./provider-usage.fetch.minimax.js"; export { fetchZaiUsage } from "./provider-usage.fetch.zai.js"; diff --git a/src/infra/provider-usage.format.test.ts b/src/infra/provider-usage.format.test.ts index fd8a6fae9f26..9c2cce9bcaf8 100644 --- a/src/infra/provider-usage.format.test.ts +++ b/src/infra/provider-usage.format.test.ts @@ -93,6 +93,27 @@ describe("provider-usage.format", () => { ); }); + it("formats provider summary text for balance-only providers", () => { + const summary: UsageSummary = { + updatedAt: now, + providers: [ + { + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }, + ], + }; + + expect(formatUsageWindowSummary(summary.providers[0], { now })).toBe("Balance ¥42.50"); + expect(formatUsageSummaryLine(summary, { now })).toBe("📊 Usage: DeepSeek Balance ¥42.50"); + expect(formatUsageReportLines(summary, { now })).toEqual([ + "Usage:", + " DeepSeek: Balance ¥42.50", + ]); + }); + it("returns null summary line when providers are errored or have no windows", () => { expect( formatUsageSummaryLine({ @@ -143,6 +164,23 @@ describe("provider-usage.format", () => { opts: undefined, expected: ["Usage:", " Codex (Plus): Token expired", " Xiaomi: no data"], }, + { + name: "formats plan plus summary entries without windows", + summary: { + updatedAt: now, + providers: [ + { + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥0.00", + plan: "Unavailable", + }, + ], + } as UsageSummary, + opts: undefined, + expected: ["Usage:", " DeepSeek (Unavailable): Balance ¥0.00"], + }, { name: "formats detailed report lines with reset windows", summary: { diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index 5cfe6ba0ef12..4fc4a632d8bc 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -48,7 +48,7 @@ export function formatUsageWindowSummary( return null; } if (snapshot.windows.length === 0) { - return null; + return snapshot.summary?.trim() || null; } const now = opts?.now ?? Date.now(); const maxWindows = @@ -71,13 +71,16 @@ export function formatUsageSummaryLine( opts?: { now?: number; maxProviders?: number }, ): string | null { const providers = summary.providers - .filter((entry) => entry.windows.length > 0 && !entry.error) + .filter((entry) => (entry.windows.length > 0 || Boolean(entry.summary?.trim())) && !entry.error) .slice(0, opts?.maxProviders ?? summary.providers.length); if (providers.length === 0) { return null; } const parts = providers.map((entry) => { + if (entry.windows.length === 0 && entry.summary?.trim()) { + return `${entry.displayName} ${entry.summary.trim()}`; + } const window = entry.windows.reduce((best, next) => next.usedPercent > best.usedPercent ? next : best, ); @@ -99,10 +102,13 @@ export function formatUsageReportLines(summary: UsageSummary, opts?: { now?: num continue; } if (entry.windows.length === 0) { - lines.push(` ${entry.displayName}${planSuffix}: no data`); + lines.push(` ${entry.displayName}${planSuffix}: ${entry.summary?.trim() || "no data"}`); continue; } lines.push(` ${entry.displayName}${planSuffix}`); + if (entry.summary?.trim()) { + lines.push(` ${entry.summary.trim()}`); + } for (const window of entry.windows) { const remaining = clampPercent(100 - window.usedPercent); const reset = formatResetRemaining(window.resetAt, opts?.now); diff --git a/src/infra/provider-usage.load.test.ts b/src/infra/provider-usage.load.test.ts index 1f1fb89ae385..109af75973e7 100644 --- a/src/infra/provider-usage.load.test.ts +++ b/src/infra/provider-usage.load.test.ts @@ -144,6 +144,33 @@ describe("provider-usage.load", () => { } }); + it("keeps balance-only summary snapshots", async () => { + resolveProviderUsageSnapshotWithPluginMock.mockResolvedValueOnce({ + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }); + const mockFetch = createProviderUsageFetch(async () => { + throw new Error("legacy fetch should not run"); + }); + + const summary = await loadUsageWithAuth( + loadProviderUsageSummary, + [{ provider: "deepseek", token: "token-d" }], + mockFetch, + ); + + expect(summary.providers).toEqual([ + { + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }, + ]); + }); + it("keeps usage summary available when one provider fetch rejects", async () => { resolveProviderUsageSnapshotWithPluginMock.mockImplementation( async ({ provider }): Promise => { diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index f121bc4adffd..dd145f9f4dd4 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -138,6 +138,9 @@ export async function loadProviderUsageSummary( if (entry.windows.length > 0) { return true; } + if (entry.summary?.trim()) { + return true; + } if (!entry.error) { return true; } diff --git a/src/infra/provider-usage.shared.test.ts b/src/infra/provider-usage.shared.test.ts index d815eabe64db..e981fdf39f07 100644 --- a/src/infra/provider-usage.shared.test.ts +++ b/src/infra/provider-usage.shared.test.ts @@ -9,6 +9,7 @@ describe("provider-usage.shared", () => { }); it.each([ + { value: "deepseek", expected: "deepseek" }, { value: "zai", expected: "zai" }, { value: "z-ai", expected: undefined }, { value: " GOOGLE-GEMINI-CLI ", expected: "google-gemini-cli" }, diff --git a/src/infra/provider-usage.shared.ts b/src/infra/provider-usage.shared.ts index 5e211ae160e3..d094f16b9814 100644 --- a/src/infra/provider-usage.shared.ts +++ b/src/infra/provider-usage.shared.ts @@ -6,6 +6,7 @@ export const DEFAULT_TIMEOUT_MS = 5000; export const PROVIDER_LABELS: Record = { anthropic: "Claude", + deepseek: "DeepSeek", "github-copilot": "Copilot", "google-gemini-cli": "Gemini", minimax: "MiniMax", @@ -17,6 +18,7 @@ export const PROVIDER_LABELS: Record = { export const usageProviders: UsageProviderId[] = [ "anthropic", + "deepseek", "github-copilot", "google-gemini-cli", "minimax", diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index a849ed08b9ff..b50eaf10963e 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -61,6 +61,23 @@ describe("provider usage formatting", () => { expect(lines.join("\n")).toContain("Codex: Token expired"); }); + it("prints balance-only provider summary output", () => { + const summary: UsageSummary = { + updatedAt: 0, + providers: [ + { + provider: "deepseek", + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }, + ], + }; + + expect(formatUsageSummaryLine(summary)).toBe("📊 Usage: DeepSeek Balance ¥42.50"); + expect(formatUsageReportLines(summary).join("\n")).toContain("DeepSeek: Balance ¥42.50"); + }); + it("includes reset countdowns in report lines", () => { const now = Date.UTC(2026, 0, 7, 0, 0, 0); const summary: UsageSummary = { @@ -96,6 +113,13 @@ describe("provider usage loading", () => { windows: [{ label: "5h", usedPercent: 75 }], plan: "Coding Plan", }; + case "deepseek": + return { + provider, + displayName: "DeepSeek", + windows: [], + summary: "Balance ¥42.50", + }; case "zai": return { provider, @@ -116,17 +140,20 @@ describe("provider usage loading", () => { loadProviderUsageSummary, [ { provider: "anthropic", token: "token-1" }, + { provider: "deepseek", token: "token-1a" }, { provider: "minimax", token: "token-1b" }, { provider: "zai", token: "token-2" }, ], mockFetch, ); - expect(summary.providers).toHaveLength(3); + expect(summary.providers).toHaveLength(4); const claude = summary.providers.find((p) => p.provider === "anthropic"); + const deepseek = summary.providers.find((p) => p.provider === "deepseek"); const minimax = summary.providers.find((p) => p.provider === "minimax"); const zai = summary.providers.find((p) => p.provider === "zai"); expect(claude?.windows[0]?.label).toBe("5h"); + expect(deepseek?.summary).toBe("Balance ¥42.50"); expect(minimax?.windows[0]?.usedPercent).toBe(75); expect(zai?.plan).toBe("Pro"); expect(mockFetch).not.toHaveBeenCalled(); diff --git a/src/infra/provider-usage.types.ts b/src/infra/provider-usage.types.ts index 7bd1923b2875..fb166829dd2d 100644 --- a/src/infra/provider-usage.types.ts +++ b/src/infra/provider-usage.types.ts @@ -8,6 +8,7 @@ export type ProviderUsageSnapshot = { provider: UsageProviderId; displayName: string; windows: UsageWindow[]; + summary?: string; plan?: string; error?: string; }; @@ -19,6 +20,7 @@ export type UsageSummary = { export type UsageProviderId = | "anthropic" + | "deepseek" | "github-copilot" | "google-gemini-cli" | "minimax" diff --git a/src/plugin-sdk/provider-usage.ts b/src/plugin-sdk/provider-usage.ts index 337575969658..ba0cc966c02c 100644 --- a/src/plugin-sdk/provider-usage.ts +++ b/src/plugin-sdk/provider-usage.ts @@ -9,6 +9,7 @@ export type { export { fetchClaudeUsage, fetchCodexUsage, + fetchDeepSeekUsage, fetchGeminiUsage, fetchMinimaxUsage, fetchZaiUsage, diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index bb46accd68b0..62f2c6a4b8a8 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -481,7 +481,7 @@ function collectCodeFiles(dir: string): string[] { return files; } -function collectDeprecatedTestBarrelImports(): Array<{ file: string; specifier: string }> { +function collectDeprecatedTestBarrelImports(): string[] { const leaks: Array<{ file: string; specifier: string }> = []; const importPatterns = [ /\b(?:import|export)\b[\s\S]*?\bfrom\s*["'](openclaw\/plugin-sdk\/(?:testing|test-utils))["']/g, @@ -509,7 +509,7 @@ function collectDeprecatedTestBarrelImports(): Array<{ file: string; specifier: } } } - return leaks; + return leaks.map((entry) => `${entry.file}: ${entry.specifier}`).toSorted(); } function collectDeprecatedPackageTestingBridgeDrift(): string[] { @@ -724,7 +724,7 @@ function collectExtensionProductionSdkSubpathImports(subpaths: ReadonlySet { - let deprecatedTestBarrelImports: Array<{ file: string; specifier: string }> = []; + let deprecatedTestBarrelImports: string[] = []; let unusedReservedSdkSubpaths: string[] = []; beforeAll(() => { diff --git a/src/status/status-text.ts b/src/status/status-text.ts index c4389c3af7af..96c2c5bc4fce 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -343,7 +343,11 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise 0) { + if ( + usageEntry && + !usageEntry.error && + (usageEntry.windows.length > 0 || Boolean(usageEntry.summary?.trim())) + ) { const summaryLine = formatUsageWindowSummary(usageEntry, { now: Date.now(), maxWindows: 2, diff --git a/ui/src/ui/control-ui-vite-config.node.test.ts b/ui/src/ui/control-ui-vite-config.node.test.ts index 764c0f1ac84c..6045c84cf767 100644 --- a/ui/src/ui/control-ui-vite-config.node.test.ts +++ b/ui/src/ui/control-ui-vite-config.node.test.ts @@ -54,7 +54,8 @@ describe("Control UI Vite config", () => { it("uses a browser-safe redactor for shared tool display imports", async () => { const plugin = controlUiBrowserOnlySharedModuleAliases(); - const resolveId = plugin.resolveId; + const resolveIdHook = plugin.resolveId; + const resolveId = typeof resolveIdHook === "function" ? resolveIdHook : resolveIdHook?.handler; if (typeof resolveId !== "function") { throw new Error("Expected browser-only shared module alias plugin to expose resolveId"); }