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 <tangli1987118@hotmail.com>
Co-authored-by: litang9 <141409885+litang9@users.noreply.github.com>
This commit is contained in:
litang9
2026-06-01 00:35:41 +08:00
committed by GitHub
parent fa0a323ebd
commit d446c26acb
27 changed files with 557 additions and 33 deletions

View File

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

View File

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

View File

@@ -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 |
</Accordion>
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.
<Accordion title="Auth and security subpaths">
| Subpath | Key exports |
| --- | --- |

View File

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

View File

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

View File

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

View File

@@ -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<GetShellPathFromLoginShell>(() => "/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<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_EXEC_SHELL_SNAPSHOT"]);
envSnapshot = captureEnv([...ENV_KEYS]);
process.env.OPENCLAW_EXEC_SHELL_SNAPSHOT = "0";
});

View File

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

View File

@@ -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<UsageSummary> => 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: {

View File

@@ -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<UsageProviderId>(["deepseek"]);
type ProviderUsageStatus = Pick<ProviderUsageSnapshot, "windows" | "summary" | "plan">;
/**
* 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<string, { windows: UsageWindow[]; plan?: string }>,
usageByProvider: Map<string, ProviderUsageStatus>,
expectsOAuthSet: Set<string>,
): 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<string, { windows: UsageWindow[]; plan?: string }>();
const usageByProvider = new Map<string, ProviderUsageStatus>();
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,

View File

@@ -925,7 +925,9 @@ describe("startGatewayPostAttachRuntime", () => {
onPostReadySidecars,
onGatewayLifetimeSidecars,
onSidecarsReady: () => {
setImmediate(postReadyRequestTurn);
setImmediate(() => {
postReadyRequestTurn();
});
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ProviderUsageSnapshot | null> => {

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ export const DEFAULT_TIMEOUT_MS = 5000;
export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
anthropic: "Claude",
deepseek: "DeepSeek",
"github-copilot": "Copilot",
"google-gemini-cli": "Gemini",
minimax: "MiniMax",
@@ -17,6 +18,7 @@ export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
export const usageProviders: UsageProviderId[] = [
"anthropic",
"deepseek",
"github-copilot",
"google-gemini-cli",
"minimax",

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ export type {
export {
fetchClaudeUsage,
fetchCodexUsage,
fetchDeepSeekUsage,
fetchGeminiUsage,
fetchMinimaxUsage,
fetchZaiUsage,

View File

@@ -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<strin
}
describe("plugin-sdk package contract guardrails", () => {
let deprecatedTestBarrelImports: Array<{ file: string; specifier: string }> = [];
let deprecatedTestBarrelImports: string[] = [];
let unusedReservedSdkSubpaths: string[] = [];
beforeAll(() => {

View File

@@ -343,7 +343,11 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise<st
}
});
const usageEntry = usageSummary.providers[0];
if (usageEntry && !usageEntry.error && usageEntry.windows.length > 0) {
if (
usageEntry &&
!usageEntry.error &&
(usageEntry.windows.length > 0 || Boolean(usageEntry.summary?.trim()))
) {
const summaryLine = formatUsageWindowSummary(usageEntry, {
now: Date.now(),
maxWindows: 2,

View File

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