mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
co-authored by
Leon-SK668
Altay
parent
8518603392
commit
8d4d02a3cf
@@ -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 () =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user