mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -925,7 +925,9 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
onPostReadySidecars,
|
||||
onGatewayLifetimeSidecars,
|
||||
onSidecarsReady: () => {
|
||||
setImmediate(postReadyRequestTurn);
|
||||
setImmediate(() => {
|
||||
postReadyRequestTurn();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
94
src/infra/provider-usage.fetch.deepseek.test.ts
Normal file
94
src/infra/provider-usage.fetch.deepseek.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
108
src/infra/provider-usage.fetch.deepseek.ts
Normal file
108
src/infra/provider-usage.fetch.deepseek.ts
Normal 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" } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
export {
|
||||
fetchClaudeUsage,
|
||||
fetchCodexUsage,
|
||||
fetchDeepSeekUsage,
|
||||
fetchGeminiUsage,
|
||||
fetchMinimaxUsage,
|
||||
fetchZaiUsage,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user