Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
- Improved bounded source grouping so large flat directories split repeated filename families like command, plugin, doctor, and runtime files into more coherent review slices.
- Improved OpenCode malformed JSON diagnostics with output length, event kinds, and a bounded preview, thanks @rohitjavvadi.
- Fixed finding signatures so equivalent evidence remains stable across re-reviews, thanks @rohitjavvadi.
- Fixed provider exit-code classification for stdout-only authentication and quota failures, thanks @rohitjavvadi.
- Fixed Express route mapping for aliased Router imports that follow block comment banners, thanks @rohitjavvadi.
- Fixed Laravel route mapping to include array-style `Route::group` prefixes, thanks @rohitjavvadi.
- Fixed Fastify route-object mapping to emit static method arrays while ignoring dynamic entries, thanks @rohitjavvadi.
Expand Down
84 changes: 84 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,57 @@ 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);
expect(providerExitCode("", "expired API key")).toBe(4);
expect(providerExitCode("", "auth credentials not found")).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 Expand Up @@ -674,6 +726,38 @@ describe("extractOpencodeJson", () => {
}
throw new Error("expected provider auth failure");
});

it("classifies opencode stderr-style error events as provider auth failures", () => {
const stdout = JSON.stringify({
type: "error",
error: { data: { message: "auth credentials not found" } },
});

try {
extractOpencodeJson(stdout);
} catch (err) {
expect(err).toBeInstanceOf(ClawpatchError);
expect((err as ClawpatchError).exitCode).toBe(4);
return;
}
throw new Error("expected provider auth failure");
});

it("classifies opencode stderr-style error events as provider quota failures", () => {
const stdout = JSON.stringify({
type: "error",
error: { data: { message: "rate limit" } },
});

try {
extractOpencodeJson(stdout);
} catch (err) {
expect(err).toBeInstanceOf(ClawpatchError);
expect((err as ClawpatchError).exitCode).toBe(5);
return;
}
throw new Error("expected provider quota failure");
});
});

describe("parseReviewOutput", () => {
Expand Down
30 changes: 22 additions & 8 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 @@ -933,7 +933,7 @@ export function extractOpencodeJson(stdout: string): unknown {
: "unknown";
throw new ClawpatchError(
`opencode provider error: ${message}`,
providerExitCode(message),
providerExitCode("", message),
"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,24 @@ 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;
const PROVIDER_STDERR_AUTH_FAILURE_PATTERN = /auth|login|api key|unauthorized|wrong api key/iu;
const PROVIDER_STDERR_QUOTA_FAILURE_PATTERN = /quota|rate.?limit/iu;

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