diff --git a/src/provider.test.ts b/src/provider.test.ts index a6f4610..34cfd4f 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -22,6 +22,7 @@ const { parseReviewOutput, parseOrThrow, piThinkingLevel, + providerExitCode, providerJsonSchema, } = __testing; @@ -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 }); diff --git a/src/provider.ts b/src/provider.ts index 8692a86..74ddad7 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -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", ); } @@ -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", ); } @@ -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", ); } @@ -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", ); } @@ -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; @@ -1394,5 +1400,6 @@ export const __testing = { parseReviewOutput, parseOrThrow, piThinkingLevel, + providerExitCode, providerJsonSchema, };