fix(coding-agent): preserve codex web search backend error diagnostics
The Codex SSE `type:"error"` branch read only top-level `code`/`message`, so backend rejections emitted under a nested `error` or `response.error` object collapsed to `Codex error (): Unknown error`, hiding the cause (e.g. a regional/model-snapshot rejection). `response.failed` similarly dropped the error code. Add a shared `extractCodexSseError` that reads top-level, nested `error`, and `response.error` envelopes, and wire both error paths through it so the backend code and message survive in `SearchProviderError`. The existing `web_search_call` requirement is untouched. Fixes #7200
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Codex web search collapsing backend errors to `Codex error (): Unknown error`; the SSE error parser now preserves the backend code and message from top-level, nested `error`, and `response.error` envelopes ([#7200](https://github.com/can1357/oh-my-pi/issues/7200)).
|
||||
|
||||
## [17.2.2] - 2026-07-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -407,6 +407,30 @@ function buildCodexHeaders(
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a backend error `{code, message}` from a Codex SSE event, tolerating
|
||||
* the envelope shapes the ChatGPT Codex backend emits: top-level `{code,message}`,
|
||||
* a nested `error` object, and a `response.error` object (as in `response.failed`).
|
||||
* Without this the nested shapes collapse to `Codex error (): Unknown error`,
|
||||
* discarding the backend diagnostic — e.g. a regional/model-snapshot rejection (#7200).
|
||||
*/
|
||||
function extractCodexSseError(rawEvent: Record<string, unknown>): { code: string; message: string } {
|
||||
const candidates: unknown[] = [
|
||||
rawEvent,
|
||||
rawEvent.error,
|
||||
(rawEvent.response as { error?: unknown } | undefined)?.error,
|
||||
];
|
||||
let code = "";
|
||||
let message = "";
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object") continue;
|
||||
const record = candidate as Record<string, unknown>;
|
||||
if (!code && typeof record.code === "string" && record.code) code = record.code;
|
||||
if (!message && typeof record.message === "string" && record.message) message = record.message;
|
||||
}
|
||||
return { code, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Codex Responses API with web search tool enabled.
|
||||
* The caller provides the exact model id to send; retry / fallback policy
|
||||
@@ -558,13 +582,14 @@ async function callCodexSearch(
|
||||
}
|
||||
}
|
||||
} else if (eventType === "error") {
|
||||
const code = (rawEvent as { code?: string }).code ?? "";
|
||||
const message = (rawEvent as { message?: string }).message ?? "Unknown error";
|
||||
throw new SearchProviderError("codex", `Codex error (${code}): ${message}`, 500);
|
||||
const { code, message } = extractCodexSseError(rawEvent);
|
||||
throw new SearchProviderError("codex", `Codex error (${code}): ${message || "Unknown error"}`, 500);
|
||||
} else if (eventType === "response.failed") {
|
||||
const resp = (rawEvent as { response?: { error?: { message?: string } } }).response;
|
||||
const errorMessage = resp?.error?.message ?? "Request failed";
|
||||
throw new SearchProviderError("codex", `Codex request failed: ${errorMessage}`, 500);
|
||||
const { code, message } = extractCodexSseError(rawEvent);
|
||||
const detail = code
|
||||
? `Codex request failed (${code}): ${message || "Request failed"}`
|
||||
: `Codex request failed: ${message || "Request failed"}`;
|
||||
throw new SearchProviderError("codex", detail, 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -752,4 +752,44 @@ describe("searchCodex model selection", () => {
|
||||
expect(result.model).toBe("gpt-5.6-terra");
|
||||
expect(result.sources).toEqual([{ title: "Example Article", url: "https://example.com/article" }]);
|
||||
});
|
||||
|
||||
it("preserves a nested type:error code and message instead of Unknown error (#7200)", async () => {
|
||||
delete process.env.PI_CODEX_WEB_SEARCH_MODEL;
|
||||
const sse = [
|
||||
`data: ${JSON.stringify({
|
||||
type: "error",
|
||||
error: {
|
||||
code: "unsupported_region",
|
||||
message: "web_search is not available for this workspace's data residency region.",
|
||||
},
|
||||
})}`,
|
||||
"",
|
||||
].join("\n");
|
||||
const fetchMock: FetchImpl = () =>
|
||||
Promise.resolve(new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream" } }));
|
||||
|
||||
await expect(searchCodex(makeSearchParams("nested error envelope", fetchMock))).rejects.toThrow(
|
||||
"Codex error (unsupported_region): web_search is not available for this workspace's data residency region.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a structured response.failed error code and message (#7200)", async () => {
|
||||
delete process.env.PI_CODEX_WEB_SEARCH_MODEL;
|
||||
const sse = [
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.failed",
|
||||
response: {
|
||||
id: "resp_failed",
|
||||
error: { code: "model_snapshot_unavailable", message: "The requested model snapshot is unavailable." },
|
||||
},
|
||||
})}`,
|
||||
"",
|
||||
].join("\n");
|
||||
const fetchMock: FetchImpl = () =>
|
||||
Promise.resolve(new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream" } }));
|
||||
|
||||
await expect(searchCodex(makeSearchParams("structured failure", fetchMock))).rejects.toThrow(
|
||||
"Codex request failed (model_snapshot_unavailable): The requested model snapshot is unavailable.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user