fix(infra): guard malformed Claude usage responses (#111088)

* fix(infra): guard malformed Claude usage responses

* fix(infra): normalize Claude usage payloads

Co-authored-by: Leon-SK668 <17695126+Leon-SK668@users.noreply.github.com>

---------

Co-authored-by: Leon-SK668 <17695126+Leon-SK668@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Leon-SK668
2026-07-20 03:26:33 -07:00
committed by GitHub
co-authored by Leon-SK668 Peter Steinberger
parent 7fe1d70a50
commit fab745b82d
2 changed files with 185 additions and 67 deletions
@@ -256,6 +256,90 @@ describe("fetchClaudeUsage", () => {
expect(result.windows).toHaveLength(0);
});
it.each([
{ name: "null", payload: null },
{ name: "array", payload: [] },
])("treats a successful top-level $name as an empty usage snapshot", async ({ payload }) => {
const mockFetch = createProviderUsageFetch(async () => makeResponse(200, payload));
const result = await fetchClaudeUsage("token", 5000, mockFetch);
expect(result.error).toBeUndefined();
expect(result.windows).toEqual([]);
expect(result.billing).toBeUndefined();
});
it("ignores a non-array limits value", async () => {
const mockFetch = createProviderUsageFetch(async () =>
makeResponse(200, { limits: { percent: 27 } }),
);
const result = await fetchClaudeUsage("token", 5000, mockFetch);
expect(result.error).toBeUndefined();
expect(result.windows).toEqual([]);
});
it("skips malformed limits while preserving valid usage windows", async () => {
const mockFetch = createProviderUsageFetch(async () =>
makeResponse(200, {
limits: [
null,
"malformed",
{
percent: 27,
is_active: true,
scope: { model: { display_name: "Fable" } },
},
],
}),
);
const result = await fetchClaudeUsage("token", 5000, mockFetch);
expect(result.error).toBeUndefined();
expect(result.windows).toEqual([{ label: "Fable", usedPercent: 27, resetAt: undefined }]);
});
it("skips malformed nested windows without masking a valid model window", async () => {
const mockFetch = createProviderUsageFetch(async () =>
makeResponse(200, {
five_hour: { utilization: "18" },
seven_day: null,
seven_day_sonnet: {},
seven_day_opus: { utilization: 44 },
extra_usage: { is_enabled: "false", utilization: 12 },
}),
);
const result = await fetchClaudeUsage("token", 5000, mockFetch);
expect(result.error).toBeUndefined();
expect(result.windows).toEqual([{ label: "Opus", usedPercent: 44 }]);
});
it("defaults malformed extra-usage currency without dropping valid billing", async () => {
const mockFetch = createProviderUsageFetch(async () =>
makeResponse(200, {
extra_usage: {
is_enabled: true,
monthly_limit: 10_000,
used_credits: 500,
utilization: 5,
currency: 123,
},
}),
);
const result = await fetchClaudeUsage("token", 5000, mockFetch);
expect(result.error).toBeUndefined();
expect(result.windows).toEqual([]);
expect(result.billing).toEqual([
{ type: "budget", used: 5, limit: 100, unit: "USD", period: "month" },
]);
});
it("falls back to claude web usage when oauth scope is missing", async () => {
vi.stubEnv("CLAUDE_AI_SESSION_KEY", "sk-ant-session-key");
@@ -346,6 +430,11 @@ describe("fetchClaudeUsage", () => {
orgResponse: () => makeResponse(200, [{}]),
usageResponse: () => makeResponse(200, {}),
},
{
name: "org list has a malformed id",
orgResponse: () => makeResponse(200, [{ uuid: 123 }]),
usageResponse: () => makeResponse(200, {}),
},
{
name: "usage request fails",
orgResponse: makeOrgAResponse,
@@ -356,6 +445,11 @@ describe("fetchClaudeUsage", () => {
orgResponse: makeOrgAResponse,
usageResponse: () => makeResponse(200, {}),
},
{
name: "usage request returns null",
orgResponse: makeOrgAResponse,
usageResponse: () => makeResponse(200, null),
},
])(
"returns oauth error when web fallback is unavailable: $name",
async ({ orgResponse, usageResponse }) => {
+91 -67
View File
@@ -1,4 +1,7 @@
// Fetches Claude provider usage windows.
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 { readProviderJsonResponse } from "../agents/provider-http-errors.js";
import {
buildUsageHttpErrorSnapshot,
@@ -10,76 +13,99 @@ import {
import { clampPercent, PROVIDER_LABELS } from "./provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageWindow } from "./provider-usage.types.js";
type ClaudeUsageResponse = {
five_hour?: { utilization?: number; resets_at?: string };
seven_day?: { utilization?: number; resets_at?: string };
seven_day_sonnet?: { utilization?: number };
seven_day_opus?: { utilization?: number };
limits?: Array<{
percent?: number;
resets_at?: string;
is_active?: boolean;
scope?: { model?: { id?: string; display_name?: string } };
}>;
extra_usage?: {
is_enabled?: boolean;
monthly_limit?: number;
used_credits?: number;
utilization?: number;
currency?: string;
};
type NormalizedClaudeExtraUsage = {
enabled: boolean;
monthlyLimit?: number;
usedCredits?: number;
utilization?: number;
currency?: string;
};
type ClaudeWebOrganizationsResponse = Array<{
uuid?: string;
name?: string;
}>;
type NormalizedClaudeUsage = {
data: Record<string, unknown>;
extraUsage?: NormalizedClaudeExtraUsage;
};
function normalizeClaudeUsage(value: unknown): NormalizedClaudeUsage {
const data = isRecord(value) ? value : {};
const rawExtraUsage = isRecord(data.extra_usage) ? data.extra_usage : undefined;
const extraUsage = rawExtraUsage
? {
enabled: rawExtraUsage.is_enabled === true,
monthlyLimit: asFiniteNumber(rawExtraUsage.monthly_limit),
usedCredits: asFiniteNumber(rawExtraUsage.used_credits),
utilization: asFiniteNumber(rawExtraUsage.utilization),
currency: normalizeOptionalString(rawExtraUsage.currency),
}
: undefined;
return { data, extraUsage };
}
function readClaudeWindow(
data: Record<string, unknown>,
key: string,
label: string,
): UsageWindow | undefined {
const rawWindow = isRecord(data[key]) ? data[key] : undefined;
const utilization = asFiniteNumber(rawWindow?.utilization);
if (utilization === undefined) {
return undefined;
}
return {
label,
usedPercent: clampPercent(utilization),
...(key === "five_hour" || key === "seven_day"
? { resetAt: parseUsageResetAt(rawWindow?.resets_at) }
: {}),
};
}
function buildClaudeUsageWindows(
data: ClaudeUsageResponse,
usage: NormalizedClaudeUsage,
options?: { skipExtraUsage?: boolean },
): UsageWindow[] {
const { data, extraUsage } = usage;
const windows: UsageWindow[] = [];
if (data.five_hour?.utilization !== undefined) {
windows.push({
label: "5h",
usedPercent: clampPercent(data.five_hour.utilization),
resetAt: parseUsageResetAt(data.five_hour.resets_at),
});
const fiveHour = readClaudeWindow(data, "five_hour", "5h");
if (fiveHour) {
windows.push(fiveHour);
}
if (data.seven_day?.utilization !== undefined) {
windows.push({
label: "Week",
usedPercent: clampPercent(data.seven_day.utilization),
resetAt: parseUsageResetAt(data.seven_day.resets_at),
});
const sevenDay = readClaudeWindow(data, "seven_day", "Week");
if (sevenDay) {
windows.push(sevenDay);
}
const modelWindow = data.seven_day_sonnet || data.seven_day_opus;
if (modelWindow?.utilization !== undefined) {
windows.push({
label: data.seven_day_sonnet ? "Sonnet" : "Opus",
usedPercent: clampPercent(modelWindow.utilization),
});
const modelWindow =
readClaudeWindow(data, "seven_day_sonnet", "Sonnet") ??
readClaudeWindow(data, "seven_day_opus", "Opus");
if (modelWindow) {
windows.push(modelWindow);
}
const knownLabels = new Set(windows.map((window) => window.label.toLowerCase()));
for (const limit of data.limits ?? []) {
if (limit.is_active === false || !Number.isFinite(limit.percent)) {
const limits = Array.isArray(data.limits) ? data.limits : [];
for (const rawLimit of limits) {
if (!isRecord(rawLimit)) {
continue;
}
const model = limit.scope?.model;
const label = model?.display_name?.trim() || model?.id?.trim();
const percent = asFiniteNumber(rawLimit.percent);
if (rawLimit.is_active === false || percent === undefined) {
continue;
}
const scope = isRecord(rawLimit.scope) ? rawLimit.scope : undefined;
const model = scope && isRecord(scope.model) ? scope.model : undefined;
const label =
normalizeOptionalString(model?.display_name) ?? normalizeOptionalString(model?.id);
if (!label || knownLabels.has(label.toLowerCase())) {
continue;
}
knownLabels.add(label.toLowerCase());
windows.push({
label,
usedPercent: clampPercent(limit.percent ?? 0),
resetAt: parseUsageResetAt(limit.resets_at),
usedPercent: clampPercent(percent),
resetAt: parseUsageResetAt(rawLimit.resets_at),
});
}
@@ -87,12 +113,12 @@ function buildClaudeUsageWindows(
// rendering both would duplicate the same credits as window and budget.
if (
!options?.skipExtraUsage &&
data.extra_usage?.is_enabled &&
Number.isFinite(data.extra_usage.utilization)
extraUsage?.enabled === true &&
extraUsage.utilization !== undefined
) {
windows.push({
label: "Extra usage",
usedPercent: clampPercent(data.extra_usage.utilization ?? 0),
usedPercent: clampPercent(extraUsage.utilization),
});
}
@@ -141,8 +167,8 @@ async function fetchClaudeWebUsage(
if (!parsedOrgs.ok) {
return null;
}
const orgs = parsedOrgs.data as ClaudeWebOrganizationsResponse;
const orgId = orgs?.[0]?.uuid?.trim();
const firstOrg = Array.isArray(parsedOrgs.data) ? parsedOrgs.data[0] : undefined;
const orgId = isRecord(firstOrg) ? normalizeOptionalString(firstOrg.uuid) : undefined;
if (!orgId) {
return null;
}
@@ -162,8 +188,8 @@ async function fetchClaudeWebUsage(
if (!parsedUsage.ok) {
return null;
}
const data = parsedUsage.data as ClaudeUsageResponse;
const windows = buildClaudeUsageWindows(data);
const usage = normalizeClaudeUsage(parsedUsage.data);
const windows = buildClaudeUsageWindows(usage);
if (windows.length === 0) {
return null;
@@ -233,29 +259,27 @@ export async function fetchClaudeUsage(
if (!parsed.ok) {
return parsed.snapshot;
}
const data = parsed.data as ClaudeUsageResponse;
const extra = data.extra_usage;
const unit = extra?.currency?.trim().toUpperCase() || "USD";
const usage = normalizeClaudeUsage(parsed.data);
const extra = usage.extraUsage;
const unit = extra?.currency?.toUpperCase() || "USD";
const billing =
extra?.is_enabled === true &&
typeof extra.used_credits === "number" &&
Number.isFinite(extra.used_credits) &&
extra.used_credits >= 0 &&
typeof extra.monthly_limit === "number" &&
Number.isFinite(extra.monthly_limit) &&
extra.monthly_limit >= 0
extra?.enabled === true &&
extra.usedCredits !== undefined &&
extra.usedCredits >= 0 &&
extra.monthlyLimit !== undefined &&
extra.monthlyLimit >= 0
? [
{
type: "budget" as const,
// Anthropic reports extra-usage currency in minor units.
used: extra.used_credits / 100,
limit: extra.monthly_limit / 100,
used: extra.usedCredits / 100,
limit: extra.monthlyLimit / 100,
unit,
period: "month",
},
]
: undefined;
const windows = buildClaudeUsageWindows(data, { skipExtraUsage: Boolean(billing) });
const windows = buildClaudeUsageWindows(usage, { skipExtraUsage: Boolean(billing) });
return {
provider: "anthropic",