mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
fix: surface Claude CLI API errors
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseCliJson, parseCliJsonl } from "./cli-output.js";
|
||||
import { extractCliErrorMessage, parseCliJson, parseCliJsonl } from "./cli-output.js";
|
||||
|
||||
describe("parseCliJson", () => {
|
||||
it("recovers mixed-output Claude session metadata from embedded JSON objects", () => {
|
||||
@@ -246,4 +246,41 @@ describe("parseCliJsonl", () => {
|
||||
usage: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts nested Claude API errors from failed stream-json output", () => {
|
||||
const message =
|
||||
"Third-party apps now draw from your extra usage, not your plan limits. We've added a $200 credit to get you started. Claim it at claude.ai/settings/usage and keep going.";
|
||||
const apiError = `API Error: 400 ${JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
message,
|
||||
},
|
||||
request_id: "req_011CZqHuXhFetYCnr8325DQc",
|
||||
})}`;
|
||||
const result = extractCliErrorMessage(
|
||||
[
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "session-api-error" }),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
model: "<synthetic>",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: apiError }],
|
||||
},
|
||||
session_id: "session-api-error",
|
||||
error: "unknown",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: true,
|
||||
result: apiError,
|
||||
session_id: "session-api-error",
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
expect(result).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,42 @@ function parseJsonRecordCandidates(raw: string): Record<string, unknown>[] {
|
||||
return parsedRecords;
|
||||
}
|
||||
|
||||
function readNestedErrorMessage(parsed: Record<string, unknown>): string | undefined {
|
||||
if (isRecord(parsed.error)) {
|
||||
const errorMessage = readNestedErrorMessage(parsed.error);
|
||||
if (errorMessage) {
|
||||
return errorMessage;
|
||||
}
|
||||
}
|
||||
if (typeof parsed.message === "string") {
|
||||
const trimmed = parsed.message.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
if (typeof parsed.error === "string") {
|
||||
const trimmed = parsed.error.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unwrapCliErrorText(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
for (const parsed of parseJsonRecordCandidates(trimmed)) {
|
||||
const nested = readNestedErrorMessage(parsed);
|
||||
if (nested) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function toCliUsage(raw: Record<string, unknown>): CliUsage | undefined {
|
||||
const pick = (key: string) =>
|
||||
typeof raw[key] === "number" && raw[key] > 0 ? raw[key] : undefined;
|
||||
@@ -174,6 +210,35 @@ function collectCliText(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function collectExplicitCliErrorText(parsed: Record<string, unknown>): string {
|
||||
const nested = readNestedErrorMessage(parsed);
|
||||
if (nested) {
|
||||
return unwrapCliErrorText(nested);
|
||||
}
|
||||
|
||||
if (parsed.is_error === true && typeof parsed.result === "string") {
|
||||
return unwrapCliErrorText(parsed.result);
|
||||
}
|
||||
|
||||
if (parsed.type === "assistant") {
|
||||
const text = collectCliText(parsed.message);
|
||||
if (/^\s*API Error:/i.test(text)) {
|
||||
return unwrapCliErrorText(text);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.type === "error") {
|
||||
const text =
|
||||
collectCliText(parsed.message) ||
|
||||
collectCliText(parsed.content) ||
|
||||
collectCliText(parsed.result) ||
|
||||
collectCliText(parsed);
|
||||
return unwrapCliErrorText(text);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function pickCliSessionId(
|
||||
parsed: Record<string, unknown>,
|
||||
backend: CliBackendConfig,
|
||||
@@ -438,3 +503,20 @@ export function parseCliOutput(params: {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function extractCliErrorMessage(raw: string): string | null {
|
||||
const parsedRecords = parseJsonRecordCandidates(raw);
|
||||
if (parsedRecords.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let errorText = "";
|
||||
for (const parsed of parsedRecords) {
|
||||
const next = collectExplicitCliErrorText(parsed);
|
||||
if (next) {
|
||||
errorText = next;
|
||||
}
|
||||
}
|
||||
|
||||
return errorText || null;
|
||||
}
|
||||
|
||||
@@ -355,6 +355,66 @@ describe("runCliAgent spawn path", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces nested Claude stream-json API errors instead of raw event output", async () => {
|
||||
const message =
|
||||
"Third-party apps now draw from your extra usage, not your plan limits. We've added a $200 credit to get you started. Claim it at claude.ai/settings/usage and keep going.";
|
||||
const apiError = `API Error: 400 ${JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
type: "invalid_request_error",
|
||||
message,
|
||||
},
|
||||
request_id: "req_011CZqHuXhFetYCnr8325DQc",
|
||||
})}`;
|
||||
|
||||
supervisorSpawnMock.mockResolvedValueOnce(
|
||||
createManagedRun({
|
||||
reason: "exit",
|
||||
exitCode: 1,
|
||||
exitSignal: null,
|
||||
durationMs: 50,
|
||||
stdout: [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "session-api-error" }),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
message: {
|
||||
model: "<synthetic>",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: apiError }],
|
||||
},
|
||||
session_id: "session-api-error",
|
||||
error: "unknown",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
is_error: true,
|
||||
result: apiError,
|
||||
session_id: "session-api-error",
|
||||
}),
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const run = executePreparedCliRun(
|
||||
buildPreparedCliRunContext({
|
||||
provider: "claude-cli",
|
||||
model: "sonnet",
|
||||
runId: "run-claude-api-error",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(run).rejects.toMatchObject({
|
||||
name: "FailoverError",
|
||||
message,
|
||||
reason: "billing",
|
||||
status: 402,
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes dangerous backend env overrides before spawn", async () => {
|
||||
mockSuccessfulCliRun();
|
||||
await executePreparedCliRun(
|
||||
|
||||
@@ -7,7 +7,12 @@ import { enqueueSystemEvent as enqueueSystemEventImpl } from "../../infra/system
|
||||
import { getProcessSupervisor as getProcessSupervisorImpl } from "../../process/supervisor/index.js";
|
||||
import { scopedHeartbeatWakeOptions } from "../../routing/session-key.js";
|
||||
import { prependBootstrapPromptWarning } from "../bootstrap-budget.js";
|
||||
import { createCliJsonlStreamingParser, parseCliOutput, type CliOutput } from "../cli-output.js";
|
||||
import {
|
||||
createCliJsonlStreamingParser,
|
||||
extractCliErrorMessage,
|
||||
parseCliOutput,
|
||||
type CliOutput,
|
||||
} from "../cli-output.js";
|
||||
import { FailoverError, resolveFailoverStatus } from "../failover-error.js";
|
||||
import { classifyFailoverReason } from "../pi-embedded-helpers.js";
|
||||
import {
|
||||
@@ -321,7 +326,11 @@ export async function executePreparedCliRun(
|
||||
status: resolveFailoverStatus("timeout"),
|
||||
});
|
||||
}
|
||||
const err = stderr || stdout || "CLI failed.";
|
||||
const primaryErrorText = stderr || stdout;
|
||||
const structuredError =
|
||||
extractCliErrorMessage(primaryErrorText) ??
|
||||
(stderr ? extractCliErrorMessage(stdout) : null);
|
||||
const err = structuredError || primaryErrorText || "CLI failed.";
|
||||
const reason = classifyFailoverReason(err, { provider: params.provider }) ?? "unknown";
|
||||
const status = resolveFailoverStatus(reason);
|
||||
throw new FailoverError(err, {
|
||||
|
||||
@@ -222,6 +222,7 @@ describe("isBillingErrorMessage", () => {
|
||||
const samples = [
|
||||
"You're out of extra usage. Add more at claude.ai/settings/usage and keep going.",
|
||||
"Extra usage is required for long context requests.",
|
||||
"Third-party apps now draw from your extra usage, not your plan limits. We've added a $200 credit to get you started. Claim it at claude.ai/settings/usage and keep going.",
|
||||
'{"type":"error","error":{"type":"invalid_request_error","message":"You\'re out of extra usage. Add more at claude.ai/settings/usage and keep going."}}',
|
||||
'{"type":"error","error":{"type":"invalid_request_error","message":"Extra usage is required for long context requests."}}',
|
||||
];
|
||||
|
||||
@@ -123,6 +123,7 @@ const ERROR_PATTERNS = {
|
||||
"insufficient usd or diem balance",
|
||||
/requires?\s+more\s+credits/i,
|
||||
/out of extra usage/i,
|
||||
/draw from your extra usage/i,
|
||||
/extra usage is required(?: for long context requests)?/i,
|
||||
],
|
||||
authPermanent: HIGH_CONFIDENCE_AUTH_PERMANENT_PATTERNS,
|
||||
|
||||
Reference in New Issue
Block a user