fix: surface Claude CLI API errors

This commit is contained in:
Peter Steinberger
2026-04-08 00:44:38 +01:00
parent 259e9abbc5
commit 4d3c72a521
6 changed files with 193 additions and 3 deletions

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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, {

View File

@@ -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."}}',
];

View File

@@ -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,