diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 2adacf98686..2d6ede4cd12 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -64,6 +64,16 @@ describe("resolveProviderAuths key normalization", () => { } } + async function writeAuthProfiles(home: string, profiles: Record) { + const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); + await fs.mkdir(agentDir, { recursive: true }); + await fs.writeFile( + path.join(agentDir, "auth-profiles.json"), + `${JSON.stringify({ version: 1, profiles }, null, 2)}\n`, + "utf8", + ); + } + it("strips embedded CR/LF from env keys", async () => { await withSuiteHome( async () => { @@ -87,23 +97,10 @@ describe("resolveProviderAuths key normalization", () => { it("strips embedded CR/LF from stored auth profiles (token + api_key)", async () => { await withSuiteHome( async (home) => { - const agentDir = path.join(home, ".openclaw", "agents", "main", "agent"); - await fs.mkdir(agentDir, { recursive: true }); - await fs.writeFile( - path.join(agentDir, "auth-profiles.json"), - `${JSON.stringify( - { - version: 1, - profiles: { - "minimax:default": { type: "token", provider: "minimax", token: "mini-\r\nmax" }, - "xiaomi:default": { type: "api_key", provider: "xiaomi", key: "xiao-\r\nmi" }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); + await writeAuthProfiles(home, { + "minimax:default": { type: "token", provider: "minimax", token: "mini-\r\nmax" }, + "xiaomi:default": { type: "api_key", provider: "xiaomi", key: "xiao-\r\nmi" }, + }); const auths = await resolveProviderAuths({ providers: ["minimax", "xiaomi"], @@ -120,4 +117,67 @@ describe("resolveProviderAuths key normalization", () => { }, ); }); + + it("returns injected auth values unchanged", async () => { + const auths = await resolveProviderAuths({ + providers: ["anthropic"], + auth: [{ provider: "anthropic", token: "token-1", accountId: "acc-1" }], + }); + expect(auths).toEqual([{ provider: "anthropic", token: "token-1", accountId: "acc-1" }]); + }); + + it("accepts z-ai env alias and normalizes embedded CR/LF", async () => { + await withSuiteHome( + async () => { + const auths = await resolveProviderAuths({ + providers: ["zai"], + }); + expect(auths).toEqual([{ provider: "zai", token: "zai-key" }]); + }, + { + ZAI_API_KEY: undefined, + Z_AI_API_KEY: "zai-\r\nkey", + }, + ); + }); + + it("falls back to legacy .pi auth file for zai keys", async () => { + await withSuiteHome( + async (home) => { + const legacyDir = path.join(home, ".pi", "agent"); + await fs.mkdir(legacyDir, { recursive: true }); + await fs.writeFile( + path.join(legacyDir, "auth.json"), + `${JSON.stringify({ "z-ai": { access: "legacy-zai-key" } }, null, 2)}\n`, + "utf8", + ); + + const auths = await resolveProviderAuths({ + providers: ["zai"], + }); + expect(auths).toEqual([{ provider: "zai", token: "legacy-zai-key" }]); + }, + { + ZAI_API_KEY: undefined, + Z_AI_API_KEY: undefined, + }, + ); + }); + + it("extracts google oauth token from JSON payload in token profiles", async () => { + await withSuiteHome(async (home) => { + await writeAuthProfiles(home, { + "google-gemini-cli:default": { + type: "token", + provider: "google-gemini-cli", + token: '{"token":"google-oauth-token"}', + }, + }); + + const auths = await resolveProviderAuths({ + providers: ["google-gemini-cli"], + }); + expect(auths).toEqual([{ provider: "google-gemini-cli", token: "google-oauth-token" }]); + }, {}); + }); }); diff --git a/src/infra/provider-usage.format.test.ts b/src/infra/provider-usage.format.test.ts new file mode 100644 index 00000000000..3063a571a24 --- /dev/null +++ b/src/infra/provider-usage.format.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest"; +import { + formatUsageReportLines, + formatUsageSummaryLine, + formatUsageWindowSummary, +} from "./provider-usage.format.js"; +import type { ProviderUsageSnapshot, UsageSummary } from "./provider-usage.types.js"; + +const now = Date.UTC(2026, 0, 7, 12, 0, 0); + +function makeSnapshot(windows: ProviderUsageSnapshot["windows"]): ProviderUsageSnapshot { + return { + provider: "anthropic", + displayName: "Claude", + windows, + }; +} + +describe("provider-usage.format", () => { + it("returns null summary for errored or empty snapshots", () => { + expect(formatUsageWindowSummary({ ...makeSnapshot([]), error: "HTTP 401" })).toBeNull(); + expect(formatUsageWindowSummary(makeSnapshot([]))).toBeNull(); + }); + + it("formats reset windows across now/minute/hour/day/date buckets", () => { + const summary = formatUsageWindowSummary( + makeSnapshot([ + { label: "Now", usedPercent: 10, resetAt: now - 1 }, + { label: "Minute", usedPercent: 20, resetAt: now + 30 * 60_000 }, + { label: "Hour", usedPercent: 30, resetAt: now + 2 * 60 * 60_000 + 15 * 60_000 }, + { label: "Day", usedPercent: 40, resetAt: now + (2 * 24 + 3) * 60 * 60_000 }, + { label: "Date", usedPercent: 50, resetAt: Date.UTC(2026, 0, 20, 12, 0, 0) }, + ]), + { now, includeResets: true }, + ); + + expect(summary).toContain("Now 90% left ⏱now"); + expect(summary).toContain("Minute 80% left ⏱30m"); + expect(summary).toContain("Hour 70% left ⏱2h 15m"); + expect(summary).toContain("Day 60% left ⏱2d 3h"); + expect(summary).toMatch(/Date 50% left ⏱[A-Z][a-z]{2} \d{1,2}/); + }); + + it("honors max windows and reset toggle", () => { + const summary = formatUsageWindowSummary( + makeSnapshot([ + { label: "A", usedPercent: 10, resetAt: now + 60_000 }, + { label: "B", usedPercent: 20, resetAt: now + 120_000 }, + { label: "C", usedPercent: 30, resetAt: now + 180_000 }, + ]), + { now, maxWindows: 2, includeResets: false }, + ); + + expect(summary).toBe("A 90% left · B 80% left"); + }); + + it("formats summary line from highest-usage window and provider cap", () => { + const summary: UsageSummary = { + updatedAt: now, + providers: [ + { + provider: "anthropic", + displayName: "Claude", + windows: [ + { label: "5h", usedPercent: 20 }, + { label: "Week", usedPercent: 70 }, + ], + }, + { + provider: "zai", + displayName: "z.ai", + windows: [{ label: "Day", usedPercent: 10 }], + }, + ], + }; + + expect(formatUsageSummaryLine(summary, { now, maxProviders: 1 })).toBe( + "📊 Usage: Claude 30% left (Week)", + ); + }); + + it("formats report output for empty, error, no-data, and plan entries", () => { + expect(formatUsageReportLines({ updatedAt: now, providers: [] })).toEqual([ + "Usage: no provider usage available.", + ]); + + const summary: UsageSummary = { + updatedAt: now, + providers: [ + { + provider: "openai-codex", + displayName: "Codex", + windows: [], + error: "Token expired", + plan: "Plus", + }, + { + provider: "xiaomi", + displayName: "Xiaomi", + windows: [], + }, + ], + }; + expect(formatUsageReportLines(summary)).toEqual([ + "Usage:", + " Codex (Plus): Token expired", + " Xiaomi: no data", + ]); + }); +}); diff --git a/src/infra/provider-usage.shared.test.ts b/src/infra/provider-usage.shared.test.ts new file mode 100644 index 00000000000..270e4cc65c4 --- /dev/null +++ b/src/infra/provider-usage.shared.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { clampPercent, resolveUsageProviderId, withTimeout } from "./provider-usage.shared.js"; + +describe("provider-usage.shared", () => { + it("normalizes supported usage provider ids", () => { + expect(resolveUsageProviderId("z-ai")).toBe("zai"); + expect(resolveUsageProviderId(" GOOGLE-ANTIGRAVITY ")).toBe("google-antigravity"); + expect(resolveUsageProviderId("unknown-provider")).toBeUndefined(); + expect(resolveUsageProviderId()).toBeUndefined(); + }); + + it("clamps usage percents and handles non-finite values", () => { + expect(clampPercent(-5)).toBe(0); + expect(clampPercent(120)).toBe(100); + expect(clampPercent(Number.NaN)).toBe(0); + expect(clampPercent(Number.POSITIVE_INFINITY)).toBe(0); + }); + + it("returns work result when it resolves before timeout", async () => { + await expect(withTimeout(Promise.resolve("ok"), 100, "fallback")).resolves.toBe("ok"); + }); + + it("returns fallback when timeout wins", async () => { + const late = new Promise((resolve) => setTimeout(() => resolve("late"), 50)); + await expect(withTimeout(late, 1, "fallback")).resolves.toBe("fallback"); + }); +}); diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index 8a2321c48da..0a3282f2251 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -3,28 +3,42 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "../agents/auth-profiles.js"; +import { createProviderUsageFetch, makeResponse } from "../test-utils/provider-usage-fetch.js"; import { formatUsageReportLines, formatUsageSummaryLine, loadProviderUsageSummary, type UsageSummary, } from "./provider-usage.js"; +import { ignoredErrors } from "./provider-usage.shared.js"; const minimaxRemainsEndpoint = "api.minimaxi.com/v1/api/openplatform/coding_plan/remains"; +const usageNow = Date.UTC(2026, 0, 7, 0, 0, 0); +type ProviderAuth = NonNullable< + NonNullable[0]>["auth"] +>[number]; -function makeResponse(status: number, body: unknown): Response { - const payload = typeof body === "string" ? body : JSON.stringify(body); - const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" }; - return new Response(payload, { status, headers }); +async function loadUsageWithAuth( + auth: ProviderAuth[], + mockFetch: ReturnType, +) { + return await loadProviderUsageSummary({ + now: usageNow, + auth, + fetch: mockFetch as unknown as typeof fetch, + }); } -function toRequestUrl(input: Parameters[0]): string { - return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; +function expectSingleAnthropicProvider(summary: UsageSummary) { + expect(summary.providers).toHaveLength(1); + const claude = summary.providers[0]; + expect(claude?.provider).toBe("anthropic"); + return claude; } function createMinimaxOnlyFetch(payload: unknown) { - return vi.fn(async (input: string | Request | URL) => { - if (toRequestUrl(input).includes(minimaxRemainsEndpoint)) { + return createProviderUsageFetch(async (url) => { + if (url.includes(minimaxRemainsEndpoint)) { return makeResponse(200, payload); } return makeResponse(404, "not found"); @@ -38,11 +52,7 @@ async function expectMinimaxUsage( ) { const mockFetch = createMinimaxOnlyFetch(payload); - const summary = await loadProviderUsageSummary({ - now: Date.UTC(2026, 0, 7, 0, 0, 0), - auth: [{ provider: "minimax", token: "token-1b" }], - fetch: mockFetch as unknown as typeof fetch, - }); + const summary = await loadUsageWithAuth([{ provider: "minimax", token: "token-1b" }], mockFetch); const minimax = summary.providers.find((p) => p.provider === "minimax"); expect(minimax?.windows[0]?.usedPercent).toBe(expectedUsedPercent); @@ -113,8 +123,7 @@ describe("provider usage formatting", () => { describe("provider usage loading", () => { it("loads usage snapshots with injected auth", async () => { - const mockFetch = vi.fn(async (input: string | Request | URL) => { - const url = toRequestUrl(input); + const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("api.anthropic.com")) { return makeResponse(200, { five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, @@ -152,15 +161,14 @@ describe("provider usage loading", () => { return makeResponse(404, "not found"); }); - const summary = await loadProviderUsageSummary({ - now: Date.UTC(2026, 0, 7, 0, 0, 0), - auth: [ + const summary = await loadUsageWithAuth( + [ { provider: "anthropic", token: "token-1" }, { provider: "minimax", token: "token-1b" }, { provider: "zai", token: "token-2" }, ], - fetch: mockFetch as unknown as typeof fetch, - }); + mockFetch, + ); expect(summary.providers).toHaveLength(3); const claude = summary.providers.find((p) => p.provider === "anthropic"); @@ -259,16 +267,7 @@ describe("provider usage loading", () => { }); expect(listProfilesForProvider(store, "anthropic")).toContain("anthropic:default"); - const makeResponse = (status: number, body: unknown): Response => { - const payload = typeof body === "string" ? body : JSON.stringify(body); - const headers = - typeof body === "string" ? undefined : { "Content-Type": "application/json" }; - return new Response(payload, { status, headers }); - }; - - const mockFetch = vi.fn(async (input: string | Request | URL, init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const mockFetch = createProviderUsageFetch(async (url, init) => { if (url.includes("api.anthropic.com/api/oauth/usage")) { const headers = (init?.headers ?? {}) as Record; expect(headers.Authorization).toBe("Bearer token-1"); @@ -283,15 +282,13 @@ describe("provider usage loading", () => { }); const summary = await loadProviderUsageSummary({ - now: Date.UTC(2026, 0, 7, 0, 0, 0), + now: usageNow, providers: ["anthropic"], agentDir, fetch: mockFetch as unknown as typeof fetch, }); - expect(summary.providers).toHaveLength(1); - const claude = summary.providers[0]; - expect(claude?.provider).toBe("anthropic"); + const claude = expectSingleAnthropicProvider(summary); expect(claude?.windows[0]?.label).toBe("5h"); expect(mockFetch).toHaveBeenCalled(); }, @@ -308,16 +305,7 @@ describe("provider usage loading", () => { const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY; process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1"; try { - const makeResponse = (status: number, body: unknown): Response => { - const payload = typeof body === "string" ? body : JSON.stringify(body); - const headers = - typeof body === "string" ? undefined : { "Content-Type": "application/json" }; - return new Response(payload, { status, headers }); - }; - - const mockFetch = vi.fn(async (input: string | Request | URL) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const mockFetch = createProviderUsageFetch(async (url) => { if (url.includes("api.anthropic.com/api/oauth/usage")) { return makeResponse(403, { type: "error", @@ -340,15 +328,12 @@ describe("provider usage loading", () => { return makeResponse(404, "not found"); }); - const summary = await loadProviderUsageSummary({ - now: Date.UTC(2026, 0, 7, 0, 0, 0), - auth: [{ provider: "anthropic", token: "sk-ant-oauth-1" }], - fetch: mockFetch as unknown as typeof fetch, - }); + const summary = await loadUsageWithAuth( + [{ provider: "anthropic", token: "sk-ant-oauth-1" }], + mockFetch, + ); - expect(summary.providers).toHaveLength(1); - const claude = summary.providers[0]; - expect(claude?.provider).toBe("anthropic"); + const claude = expectSingleAnthropicProvider(summary); expect(claude?.windows.some((w) => w.label === "5h")).toBe(true); expect(claude?.windows.some((w) => w.label === "Week")).toBe(true); } finally { @@ -359,4 +344,131 @@ describe("provider usage loading", () => { } } }); + + it("loads snapshots for copilot antigravity gemini codex and xiaomi", async () => { + const mockFetch = createProviderUsageFetch(async (url) => { + if (url.includes("api.github.com/copilot_internal/user")) { + return makeResponse(200, { + quota_snapshots: { chat: { percent_remaining: 80 } }, + copilot_plan: "Copilot Pro", + }); + } + if (url.includes("cloudcode-pa.googleapis.com/v1internal:loadCodeAssist")) { + return makeResponse(200, { + availablePromptCredits: 80, + planInfo: { monthlyPromptCredits: 100 }, + currentTier: { name: "Antigravity Pro" }, + cloudaicompanionProject: "projects/demo", + }); + } + if (url.includes("cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels")) { + return makeResponse(200, { + models: { + "gemini-2.5-pro": { + quotaInfo: { remainingFraction: 0.4, resetTime: "2026-01-08T01:00:00Z" }, + }, + }, + }); + } + if (url.includes("cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota")) { + return makeResponse(200, { + buckets: [{ modelId: "gemini-2.5-pro", remainingFraction: 0.6 }], + }); + } + if (url.includes("chatgpt.com/backend-api/wham/usage")) { + return makeResponse(200, { + rate_limit: { primary_window: { used_percent: 12, limit_window_seconds: 10800 } }, + plan_type: "Plus", + }); + } + return makeResponse(404, "not found"); + }); + + const summary = await loadUsageWithAuth( + [ + { provider: "github-copilot", token: "copilot-token" }, + { provider: "google-antigravity", token: "antigravity-token" }, + { provider: "google-gemini-cli", token: "gemini-token" }, + { provider: "openai-codex", token: "codex-token", accountId: "acc-1" }, + { provider: "xiaomi", token: "xiaomi-token" }, + ], + mockFetch, + ); + + expect(summary.providers.map((provider) => provider.provider)).toEqual([ + "github-copilot", + "google-antigravity", + "google-gemini-cli", + "openai-codex", + "xiaomi", + ]); + expect( + summary.providers.find((provider) => provider.provider === "github-copilot")?.windows, + ).toEqual([{ label: "Chat", usedPercent: 20 }]); + expect( + summary.providers.find((provider) => provider.provider === "google-antigravity")?.windows + .length, + ).toBeGreaterThan(0); + expect( + summary.providers.find((provider) => provider.provider === "google-gemini-cli")?.windows[0] + ?.label, + ).toBe("Pro"); + expect( + summary.providers.find((provider) => provider.provider === "openai-codex")?.windows[0]?.label, + ).toBe("3h"); + expect(summary.providers.find((provider) => provider.provider === "xiaomi")?.windows).toEqual( + [], + ); + }); + + it("returns empty provider list when auth resolves to none", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(404, "not found")); + const summary = await loadUsageWithAuth([], mockFetch); + expect(summary).toEqual({ updatedAt: usageNow, providers: [] }); + }); + + it("returns unsupported provider snapshots for unknown provider ids", async () => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(404, "not found")); + const summary = await loadUsageWithAuth( + [{ provider: "unsupported-provider", token: "token-u" }] as unknown as ProviderAuth[], + mockFetch, + ); + expect(summary.providers).toHaveLength(1); + expect(summary.providers[0]?.error).toBe("Unsupported provider"); + }); + + it("filters errors that are marked as ignored", async () => { + const mockFetch = createProviderUsageFetch(async (url) => { + if (url.includes("api.anthropic.com/api/oauth/usage")) { + return makeResponse(500, "boom"); + } + return makeResponse(404, "not found"); + }); + ignoredErrors.add("HTTP 500"); + try { + const summary = await loadUsageWithAuth( + [{ provider: "anthropic", token: "token-a" }], + mockFetch, + ); + expect(summary.providers).toEqual([]); + } finally { + ignoredErrors.delete("HTTP 500"); + } + }); + + it("throws when fetch is unavailable", async () => { + const previousFetch = globalThis.fetch; + vi.stubGlobal("fetch", undefined); + try { + await expect( + loadProviderUsageSummary({ + now: usageNow, + auth: [{ provider: "xiaomi", token: "token-x" }], + fetch: undefined, + }), + ).rejects.toThrow("fetch is not available"); + } finally { + vi.stubGlobal("fetch", previousFetch); + } + }); });