mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
co-authored by
Leon-SK668
Peter Steinberger
parent
7fe1d70a50
commit
fab745b82d
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user