Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions src/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const {
parseReviewOutput,
parseOrThrow,
piThinkingLevel,
providerExitCode,
providerJsonSchema,
} = __testing;

Expand Down Expand Up @@ -363,6 +364,55 @@ describe("codexFailureMessage", () => {
});
});

describe("providerExitCode", () => {
it("classifies auth failures from stdout-only provider output", () => {
expect(providerExitCode("Unauthorized: Wrong API Key", "")).toBe(4);
expect(providerExitCode("auth required", "")).toBe(4);
expect(providerExitCode("Incorrect API key provided", "")).toBe(4);
expect(providerExitCode("invalid_api_key", "")).toBe(4);
expect(providerExitCode("API key is required", "")).toBe(4);
expect(providerExitCode("API key not found", "")).toBe(4);
expect(providerExitCode("OPENAI_API_KEY is not set", "")).toBe(4);
expect(providerExitCode("insufficient permissions", "")).toBe(4);
expect(providerExitCode("api.responses.write scope is required", "")).toBe(4);
expect(providerExitCode("AuthenticationError: invalid credentials", "")).toBe(4);
expect(providerExitCode("authentication_error", "")).toBe(4);
expect(providerExitCode("AUTH_REQUIRED", "")).toBe(4);
});

it("classifies quota failures from stdout-only provider output", () => {
expect(providerExitCode("quota exceeded for this organization", "")).toBe(5);
expect(providerExitCode("You exceeded your current quota", "")).toBe(5);
expect(providerExitCode("insufficient_quota", "")).toBe(5);
expect(providerExitCode("quota_exceeded", "")).toBe(5);
expect(providerExitCode("RateLimitError: retry later", "")).toBe(5);
expect(providerExitCode("rate_limit_error", "")).toBe(5);
});

it("does not classify benign auth-looking stdout as auth failures", () => {
expect(providerExitCode("author: Jane", "")).toBe(1);
expect(providerExitCode("registered oauth-callback route", "")).toBe(1);
expect(providerExitCode("authority metadata loaded", "")).toBe(1);
});

it("does not classify generic rate-limiting discussion as quota failures", () => {
expect(providerExitCode("consider adding rate-limiting to this endpoint", "")).toBe(1);
expect(providerExitCode("document the rate limit policy for future work", "")).toBe(1);
});

it("keeps classifying real rate-limit failures", () => {
expect(providerExitCode("rate limit exceeded for this organization", "")).toBe(5);
});

it("keeps classifying stderr failures", () => {
expect(providerExitCode("", "please login before running the provider")).toBe(4);
});

it("keeps generic failures when neither stream has a known signal", () => {
expect(providerExitCode("process exited unexpectedly", "")).toBe(1);
});
});

describe("parseAcpxAgent", () => {
it("defaults null model to codex/null", () => {
expect(parseAcpxAgent(null)).toEqual({ agent: "codex", agentModel: null });
Expand Down
21 changes: 14 additions & 7 deletions src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,7 +511,7 @@ async function runPiJson(
if (result.exitCode !== 0) {
throw new ClawpatchError(
piFailureMessage(result.stdout, result.stderr),
providerExitCode(result.stderr),
providerExitCode(result.stdout, result.stderr),
"provider-failure",
);
}
Expand Down Expand Up @@ -783,7 +783,7 @@ async function runCodexJson(
if (result.exitCode !== 0) {
throw new ClawpatchError(
codexFailureMessage(result.stdout, result.stderr),
providerExitCode(result.stderr),
providerExitCode(result.stdout, result.stderr),
"provider-failure",
);
}
Expand Down Expand Up @@ -873,7 +873,7 @@ async function runOpencodeJson(
if (result.exitCode !== 0) {
throw new ClawpatchError(
opencodeFailureMessage(result.stdout, result.stderr),
providerExitCode(result.stderr),
providerExitCode(result.stdout, result.stderr),
"provider-failure",
);
}
Expand Down Expand Up @@ -1209,7 +1209,7 @@ async function runGrokJson(
if (result.exitCode !== 0) {
throw new ClawpatchError(
`grok provider failed: ${result.stderr || result.stdout}`,
providerExitCode(result.stderr),
providerExitCode(result.stdout, result.stderr),
"provider-failure",
);
}
Expand Down Expand Up @@ -1273,11 +1273,17 @@ function grokEnvelopeText(value: unknown): string | null {
return null;
}

function providerExitCode(stderr: string): number {
if (/auth|login|api key|unauthorized|wrong api key/iu.test(stderr)) {
const PROVIDER_AUTH_FAILURE_PATTERN =
/\b(?:unauthori[sz]ed|(?:wrong|incorrect|invalid|missing|no)[\s_-]+api[\s_-]*key|api[\s_-]*key\s+(?:is\s+)?(?:missing|required|invalid|expired|not[\s_-]+found|not[\s_-]+set)|[A-Z0-9_]*API[_-]?KEY\s+(?:is\s+)?(?:missing|required|invalid|expired|not[\s_-]+set)|not authenticated|auth(?:entication|orization)?[\s_-]*(?:failed|required|missing|error)|login\s+(?:required|failed)|please\s+(?:log\s*in|login)|missing scopes?|insufficient permissions?|api\.responses\.write)\b/iu;
const PROVIDER_QUOTA_FAILURE_PATTERN =
/\b(?:quota[\s_-]+(?:exceeded|exhausted|reached)|(?:exceeded|exhausted|reached)[\s_-]+(?:your[\s_-]+)?(?:current[\s_-]+)?quota|insufficient[\s_-]+quota|out[\s_-]+of[\s_-]+quota|rate[\s_-]*limit(?:ed|[\s_-]*(?:error|exceeded|reached))|too many requests)\b/iu;

function providerExitCode(stdout: string, stderr = ""): number {
const output = `${stderr}\n${stdout}`;
if (PROVIDER_AUTH_FAILURE_PATTERN.test(output)) {
return 4;
}
if (/quota|rate.?limit/iu.test(stderr)) {
if (PROVIDER_QUOTA_FAILURE_PATTERN.test(output)) {
return 5;
}
return 1;
Expand Down Expand Up @@ -1394,5 +1400,6 @@ export const __testing = {
parseReviewOutput,
parseOrThrow,
piThinkingLevel,
providerExitCode,
providerJsonSchema,
};