Skip to content

Commit 8d4d02a

Browse files
Leon-SK668altaywtf
andauthored
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 <[email protected]> Co-authored-by: Altay <[email protected]>
1 parent 8518603 commit 8d4d02a

2 files changed

Lines changed: 140 additions & 28 deletions

File tree

src/infra/provider-usage.fetch.zai.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,35 @@ describe("fetchZaiUsage", () => {
2020
expect(result.windows).toHaveLength(0);
2121
});
2222

23+
it.each([
24+
["null", null],
25+
["array", []],
26+
])("returns a stable API error for a successful %s payload", async (_name, payload) => {
27+
const mockFetch = createProviderUsageFetch(async () => makeResponse(200, payload));
28+
const result = await fetchZaiUsage("key", 5000, mockFetch);
29+
30+
expect(result.error).toBe("API error");
31+
expect(result.windows).toHaveLength(0);
32+
});
33+
34+
it.each([
35+
["missing data", { success: true, code: 200 }],
36+
["null data", { success: true, code: 200, data: null }],
37+
["array data", { success: true, code: 200, data: [] }],
38+
["null limits", { success: true, code: 200, data: { limits: null } }],
39+
["object limits", { success: true, code: 200, data: { limits: {} } }],
40+
])("treats successful payloads with %s as empty usage", async (_name, payload) => {
41+
const mockFetch = createProviderUsageFetch(async () => makeResponse(200, payload));
42+
const result = await fetchZaiUsage("key", 5000, mockFetch);
43+
44+
expect(result).toEqual({
45+
provider: "zai",
46+
displayName: "z.ai",
47+
windows: [],
48+
plan: undefined,
49+
});
50+
});
51+
2352
it("returns API message errors for unsuccessful payloads", async () => {
2453
const mockFetch = createProviderUsageFetch(async () =>
2554
makeResponse(200, {
@@ -150,6 +179,58 @@ describe("fetchZaiUsage", () => {
150179
]);
151180
});
152181

182+
it("skips malformed limit entries while preserving valid siblings", async () => {
183+
const mockFetch = createProviderUsageFetch(async () =>
184+
makeResponse(200, {
185+
success: true,
186+
code: 200,
187+
data: {
188+
planName: " Team ",
189+
limits: [
190+
null,
191+
"not-an-object",
192+
{
193+
type: "TOKENS_LIMIT",
194+
percentage: 25,
195+
unit: 3,
196+
number: 6,
197+
},
198+
{
199+
type: "TOKENS_LIMIT",
200+
percentage: 10,
201+
unit: 3,
202+
},
203+
{
204+
type: "TIME_LIMIT",
205+
percentage: "40",
206+
},
207+
],
208+
},
209+
}),
210+
);
211+
212+
const result = await fetchZaiUsage("key", 5000, mockFetch);
213+
214+
expect(result.plan).toBe("Team");
215+
expect(result.windows).toEqual([
216+
{
217+
label: "Tokens (6h)",
218+
usedPercent: 25,
219+
resetAt: undefined,
220+
},
221+
{
222+
label: "Tokens (Limit)",
223+
usedPercent: 10,
224+
resetAt: undefined,
225+
},
226+
{
227+
label: "Monthly",
228+
usedPercent: 0,
229+
resetAt: undefined,
230+
},
231+
]);
232+
});
233+
153234
it("ignores invalid nextResetTime while preserving valid ISO resets", async () => {
154235
const validReset = "2026-01-08T00:00:00Z";
155236
const mockFetch = createProviderUsageFetch(async () =>

src/infra/provider-usage.fetch.zai.ts

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
// Fetches and normalizes Z.ai provider usage records.
2+
import { asFiniteNumber } from "@openclaw/normalization-core/number-coercion";
3+
import { isRecord } from "@openclaw/normalization-core/record-coerce";
4+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
25
import {
36
buildUsageHttpErrorSnapshot,
47
discardUsageResponseBody,
@@ -9,23 +12,55 @@ import {
912
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
1013
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
1114

12-
type ZaiUsageResponse = {
13-
success?: boolean;
14-
code?: number;
15-
msg?: string;
16-
data?: {
17-
planName?: string;
18-
plan?: string;
19-
limits?: Array<{
20-
type?: string;
21-
percentage?: number;
22-
unit?: number;
23-
number?: number;
24-
nextResetTime?: string;
25-
}>;
26-
};
15+
type NormalizedZaiLimit = {
16+
type?: string;
17+
percentage?: number;
18+
unit?: number;
19+
number?: number;
20+
nextResetTime?: string;
2721
};
2822

23+
type NormalizedZaiUsage =
24+
| { ok: false; message?: string }
25+
| {
26+
ok: true;
27+
plan?: string;
28+
limits: NormalizedZaiLimit[];
29+
};
30+
31+
function normalizeZaiUsage(value: unknown): NormalizedZaiUsage | undefined {
32+
if (!isRecord(value)) {
33+
return undefined;
34+
}
35+
const message = normalizeOptionalString(value.msg);
36+
if (value.success !== true || asFiniteNumber(value.code) !== 200) {
37+
return { ok: false, message };
38+
}
39+
40+
const data = isRecord(value.data) ? value.data : {};
41+
const rawLimits = Array.isArray(data.limits) ? data.limits : [];
42+
43+
const limits: NormalizedZaiLimit[] = [];
44+
for (const rawLimit of rawLimits) {
45+
if (!isRecord(rawLimit)) {
46+
continue;
47+
}
48+
limits.push({
49+
type: normalizeOptionalString(rawLimit.type),
50+
percentage: asFiniteNumber(rawLimit.percentage),
51+
unit: asFiniteNumber(rawLimit.unit),
52+
number: asFiniteNumber(rawLimit.number),
53+
nextResetTime: normalizeOptionalString(rawLimit.nextResetTime),
54+
});
55+
}
56+
57+
return {
58+
ok: true,
59+
plan: normalizeOptionalString(data.planName) ?? normalizeOptionalString(data.plan),
60+
limits,
61+
};
62+
}
63+
2964
export async function fetchZaiUsage(
3065
apiKey: string,
3166
timeoutMs: number,
@@ -56,29 +91,26 @@ export async function fetchZaiUsage(
5691
if (!parsed.ok) {
5792
return parsed.snapshot;
5893
}
59-
const data = parsed.data as ZaiUsageResponse;
60-
if (!data.success || data.code !== 200) {
61-
const errorMessage = typeof data.msg === "string" ? data.msg.trim() : "";
94+
const usage = normalizeZaiUsage(parsed.data);
95+
if (!usage || !usage.ok) {
6296
return {
6397
provider: "zai",
6498
displayName: PROVIDER_LABELS.zai,
6599
windows: [],
66-
error: errorMessage || "API error",
100+
error: usage?.message || "API error",
67101
};
68102
}
69103

70104
const windows: UsageWindow[] = [];
71-
const limits = data.data?.limits || [];
72-
73-
for (const limit of limits) {
74-
const percent = clampPercent(limit.percentage || 0);
105+
for (const limit of usage.limits) {
106+
const percent = clampPercent(limit.percentage ?? 0);
75107
const nextReset = parseUsageResetAt(limit.nextResetTime);
76108
let windowLabel = "Limit";
77-
if (limit.unit === 1) {
109+
if (limit.unit === 1 && limit.number !== undefined) {
78110
windowLabel = `${limit.number}d`;
79-
} else if (limit.unit === 3) {
111+
} else if (limit.unit === 3 && limit.number !== undefined) {
80112
windowLabel = `${limit.number}h`;
81-
} else if (limit.unit === 5) {
113+
} else if (limit.unit === 5 && limit.number !== undefined) {
82114
windowLabel = `${limit.number}m`;
83115
}
84116

@@ -97,11 +129,10 @@ export async function fetchZaiUsage(
97129
}
98130
}
99131

100-
const planName = data.data?.planName || data.data?.plan || undefined;
101132
return {
102133
provider: "zai",
103134
displayName: PROVIDER_LABELS.zai,
104135
windows,
105-
plan: planName,
136+
plan: usage.plan,
106137
};
107138
}

0 commit comments

Comments
 (0)