diff --git a/src/infra/provider-usage.fetch.zai.test.ts b/src/infra/provider-usage.fetch.zai.test.ts index b23de25edc7a..0138c94f758d 100644 --- a/src/infra/provider-usage.fetch.zai.test.ts +++ b/src/infra/provider-usage.fetch.zai.test.ts @@ -20,6 +20,35 @@ describe("fetchZaiUsage", () => { expect(result.windows).toHaveLength(0); }); + it.each([ + ["null", null], + ["array", []], + ])("returns a stable API error for a successful %s payload", async (_name, payload) => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(200, payload)); + const result = await fetchZaiUsage("key", 5000, mockFetch); + + expect(result.error).toBe("API error"); + expect(result.windows).toHaveLength(0); + }); + + it.each([ + ["missing data", { success: true, code: 200 }], + ["null data", { success: true, code: 200, data: null }], + ["array data", { success: true, code: 200, data: [] }], + ["null limits", { success: true, code: 200, data: { limits: null } }], + ["object limits", { success: true, code: 200, data: { limits: {} } }], + ])("treats successful payloads with %s as empty usage", async (_name, payload) => { + const mockFetch = createProviderUsageFetch(async () => makeResponse(200, payload)); + const result = await fetchZaiUsage("key", 5000, mockFetch); + + expect(result).toEqual({ + provider: "zai", + displayName: "z.ai", + windows: [], + plan: undefined, + }); + }); + it("returns API message errors for unsuccessful payloads", async () => { const mockFetch = createProviderUsageFetch(async () => makeResponse(200, { @@ -150,6 +179,58 @@ describe("fetchZaiUsage", () => { ]); }); + it("skips malformed limit entries while preserving valid siblings", async () => { + const mockFetch = createProviderUsageFetch(async () => + makeResponse(200, { + success: true, + code: 200, + data: { + planName: " Team ", + limits: [ + null, + "not-an-object", + { + type: "TOKENS_LIMIT", + percentage: 25, + unit: 3, + number: 6, + }, + { + type: "TOKENS_LIMIT", + percentage: 10, + unit: 3, + }, + { + type: "TIME_LIMIT", + percentage: "40", + }, + ], + }, + }), + ); + + const result = await fetchZaiUsage("key", 5000, mockFetch); + + expect(result.plan).toBe("Team"); + expect(result.windows).toEqual([ + { + label: "Tokens (6h)", + usedPercent: 25, + resetAt: undefined, + }, + { + label: "Tokens (Limit)", + usedPercent: 10, + resetAt: undefined, + }, + { + label: "Monthly", + usedPercent: 0, + resetAt: undefined, + }, + ]); + }); + it("ignores invalid nextResetTime while preserving valid ISO resets", async () => { const validReset = "2026-01-08T00:00:00Z"; const mockFetch = createProviderUsageFetch(async () => diff --git a/src/infra/provider-usage.fetch.zai.ts b/src/infra/provider-usage.fetch.zai.ts index 1ad593481357..4d4507f272c3 100644 --- a/src/infra/provider-usage.fetch.zai.ts +++ b/src/infra/provider-usage.fetch.zai.ts @@ -1,4 +1,7 @@ // Fetches and normalizes Z.ai provider usage records. +import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { buildUsageHttpErrorSnapshot, discardUsageResponseBody, @@ -9,23 +12,55 @@ import { import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js"; import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js"; -type ZaiUsageResponse = { - success?: boolean; - code?: number; - msg?: string; - data?: { - planName?: string; - plan?: string; - limits?: Array<{ - type?: string; - percentage?: number; - unit?: number; - number?: number; - nextResetTime?: string; - }>; - }; +type NormalizedZaiLimit = { + type?: string; + percentage?: number; + unit?: number; + number?: number; + nextResetTime?: string; }; +type NormalizedZaiUsage = + | { ok: false; message?: string } + | { + ok: true; + plan?: string; + limits: NormalizedZaiLimit[]; + }; + +function normalizeZaiUsage(value: unknown): NormalizedZaiUsage | undefined { + if (!isRecord(value)) { + return undefined; + } + const message = normalizeOptionalString(value.msg); + if (value.success !== true || asFiniteNumber(value.code) !== 200) { + return { ok: false, message }; + } + + const data = isRecord(value.data) ? value.data : {}; + const rawLimits = Array.isArray(data.limits) ? data.limits : []; + + const limits: NormalizedZaiLimit[] = []; + for (const rawLimit of rawLimits) { + if (!isRecord(rawLimit)) { + continue; + } + limits.push({ + type: normalizeOptionalString(rawLimit.type), + percentage: asFiniteNumber(rawLimit.percentage), + unit: asFiniteNumber(rawLimit.unit), + number: asFiniteNumber(rawLimit.number), + nextResetTime: normalizeOptionalString(rawLimit.nextResetTime), + }); + } + + return { + ok: true, + plan: normalizeOptionalString(data.planName) ?? normalizeOptionalString(data.plan), + limits, + }; +} + export async function fetchZaiUsage( apiKey: string, timeoutMs: number, @@ -56,29 +91,26 @@ export async function fetchZaiUsage( if (!parsed.ok) { return parsed.snapshot; } - const data = parsed.data as ZaiUsageResponse; - if (!data.success || data.code !== 200) { - const errorMessage = typeof data.msg === "string" ? data.msg.trim() : ""; + const usage = normalizeZaiUsage(parsed.data); + if (!usage || !usage.ok) { return { provider: "zai", displayName: PROVIDER_LABELS.zai, windows: [], - error: errorMessage || "API error", + error: usage?.message || "API error", }; } const windows: UsageWindow[] = []; - const limits = data.data?.limits || []; - - for (const limit of limits) { - const percent = clampPercent(limit.percentage || 0); + for (const limit of usage.limits) { + const percent = clampPercent(limit.percentage ?? 0); const nextReset = parseUsageResetAt(limit.nextResetTime); let windowLabel = "Limit"; - if (limit.unit === 1) { + if (limit.unit === 1 && limit.number !== undefined) { windowLabel = `${limit.number}d`; - } else if (limit.unit === 3) { + } else if (limit.unit === 3 && limit.number !== undefined) { windowLabel = `${limit.number}h`; - } else if (limit.unit === 5) { + } else if (limit.unit === 5 && limit.number !== undefined) { windowLabel = `${limit.number}m`; } @@ -97,11 +129,10 @@ export async function fetchZaiUsage( } } - const planName = data.data?.planName || data.data?.plan || undefined; return { provider: "zai", displayName: PROVIDER_LABELS.zai, windows, - plan: planName, + plan: usage.plan, }; }