fix(usage): guard malformed Z.AI usage payloads (#110741)

* fix(usage): guard malformed Z.ai usage payloads

* fix(usage): normalize Z.ai usage payloads

* fix(usage): preserve empty Z.ai snapshots

---------

Co-authored-by: Leon-SK668 <17695126+Leon-SK668@users.noreply.github.com>
Co-authored-by: Altay <altay@hey.com>
This commit is contained in:
Leon-SK668
2026-07-20 16:14:42 +03:00
committed by GitHub
co-authored by Leon-SK668 Altay
parent 8518603392
commit 8d4d02a3cf
2 changed files with 140 additions and 28 deletions
@@ -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 () =>
+59 -28
View File
@@ -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,
};
}