openai-codex: gate scope failures to codex

This commit is contained in:
Eva
2026-04-10 19:24:21 +07:00
committed by Peter Steinberger
parent 8166d592d9
commit 0b02b5abd2
6 changed files with 57 additions and 13 deletions

View File

@@ -180,14 +180,14 @@ describe("buildApiErrorObservationFields", () => {
expect(observed.rawErrorPreview).toContain("custom");
});
it("records runtime failure kind for missing-scope auth payloads", () => {
it("keeps provider-less missing-scope auth payloads out of the codex-specific scope lane", () => {
const observed = buildApiErrorObservationFields(
'401 {"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write"}}',
);
expect(observed).toMatchObject({
httpCode: "401",
providerRuntimeFailureKind: "auth_scope",
providerRuntimeFailureKind: "unknown",
});
});
});

View File

@@ -104,7 +104,10 @@ function buildObservationFingerprint(params: {
return getApiErrorPayloadFingerprint(params.raw);
}
export function buildApiErrorObservationFields(rawError?: string): {
export function buildApiErrorObservationFields(
rawError?: string,
opts?: { provider?: string },
): {
rawErrorPreview?: string;
rawErrorHash?: string;
rawErrorFingerprint?: string;
@@ -146,6 +149,7 @@ export function buildApiErrorObservationFields(rawError?: string): {
providerRuntimeFailureKind: classifyProviderRuntimeFailureKind({
status: parsed?.httpCode ? Number(parsed.httpCode) : undefined,
message: trimmed,
provider: opts?.provider,
}),
providerErrorType: parsed?.type,
providerErrorMessagePreview: truncateForObservation(
@@ -159,7 +163,10 @@ export function buildApiErrorObservationFields(rawError?: string): {
}
}
export function buildTextObservationFields(text?: string): {
export function buildTextObservationFields(
text?: string,
opts?: { provider?: string },
): {
textPreview?: string;
textHash?: string;
textFingerprint?: string;
@@ -169,7 +176,7 @@ export function buildTextObservationFields(text?: string): {
providerErrorMessagePreview?: string;
requestIdHash?: string;
} {
const observed = buildApiErrorObservationFields(text);
const observed = buildApiErrorObservationFields(text, opts);
return {
textPreview: observed.rawErrorPreview,
textHash: observed.rawErrorHash,

View File

@@ -228,11 +228,20 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError(
'401 {"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write model.request"}}',
);
expect(formatAssistantErrorText(msg)).toBe(
expect(formatAssistantErrorText(msg, { provider: "openai-codex" })).toBe(
"Authentication is missing the required OpenAI Codex scopes. Re-run OpenAI/Codex login and try again.",
);
});
it("does not misdiagnose non-Codex permission errors as missing-scope failures", () => {
const msg = makeAssistantError(
'401 {"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write model.request"}}',
);
expect(formatAssistantErrorText(msg, { provider: "openai" })).not.toContain(
"required OpenAI Codex scopes",
);
});
it("returns an HTML-403 auth message for HTML provider auth failures", () => {
const msg = makeAssistantError("403 <!DOCTYPE html><html><body>Access denied</body></html>");
expect(formatAssistantErrorText(msg)).toBe(

View File

@@ -1106,12 +1106,24 @@ describe("classifyFailoverReason", () => {
describe("classifyProviderRuntimeFailureKind", () => {
it("classifies missing scope failures", () => {
expect(
classifyProviderRuntimeFailureKind(
'401 {"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write"}}',
),
classifyProviderRuntimeFailureKind({
provider: "openai-codex",
message:
'401 {"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write"}}',
}),
).toBe("auth_scope");
});
it("does not classify non-Codex permission errors as missing scope failures", () => {
expect(
classifyProviderRuntimeFailureKind({
provider: "openai",
message:
'401 {"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write"}}',
}),
).not.toBe("auth_scope");
});
it("classifies OAuth refresh failures", () => {
expect(
classifyProviderRuntimeFailureKind(

View File

@@ -502,10 +502,22 @@ function isHtmlErrorResponse(raw: string, status?: number): boolean {
return HTML_BODY_RE.test(rest) && HTML_CLOSE_RE.test(rest);
}
function isAuthScopeErrorMessage(raw: string, status?: number): boolean {
function isOpenAICodexScopeContext(raw: string, provider?: string): boolean {
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
return (
normalizedProvider === "openai-codex" ||
/\bopenai\s+codex\b/i.test(raw) ||
/\bcodex\b.*\bscopes?\b/i.test(raw)
);
}
function isAuthScopeErrorMessage(raw: string, status?: number, provider?: string): boolean {
if (!raw) {
return false;
}
if (!isOpenAICodexScopeContext(raw, provider)) {
return false;
}
const inferred =
typeof status === "number" && Number.isFinite(status)
? status
@@ -916,7 +928,7 @@ export function classifyProviderRuntimeFailureKind(
if (message && classifyOAuthRefreshFailure(message)) {
return "auth_refresh";
}
if (message && isAuthScopeErrorMessage(message, status)) {
if (message && isAuthScopeErrorMessage(message, status, normalizedSignal.provider)) {
return "auth_scope";
}
if (message && status === 403 && isHtmlErrorResponse(message, status)) {

View File

@@ -60,9 +60,13 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext): void | Promise<
provider: lastAssistant.provider,
});
const errorText = (friendlyError || lastAssistant.errorMessage || "LLM request failed.").trim();
const observedError = buildApiErrorObservationFields(rawError);
const observedError = buildApiErrorObservationFields(rawError, {
provider: lastAssistant.provider,
});
const safeErrorText =
buildTextObservationFields(errorText).textPreview ?? "LLM request failed.";
buildTextObservationFields(errorText, {
provider: lastAssistant.provider,
}).textPreview ?? "LLM request failed.";
lifecycleErrorText = safeErrorText;
const safeRunId = sanitizeForConsole(ctx.params.runId) ?? "-";
const safeModel = sanitizeForConsole(lastAssistant.model) ?? "unknown";