Skip to content

Commit a971442

Browse files
SJY051lidge-jun
andauthored
fix: relay Codex alpha search requests (#89)
* release: v2.7.5 * fix: relay Codex alpha search requests --------- Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
1 parent 8910e54 commit a971442

4 files changed

Lines changed: 529 additions & 4 deletions

File tree

src/server/index.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ export {
118118
import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
119119
export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
120120
import { handleImages } from "./images";
121+
import { handleSearch } from "./search";
121122
import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
122123

123124
const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
@@ -331,6 +332,33 @@ export function startServer(port?: number) {
331332
return withCors(response, req, config);
332333
}
333334

335+
if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
336+
disableResponsesRequestTimeout(req, requestServer);
337+
if (isDraining()) {
338+
return new Response("Service shutting down", {
339+
status: 503,
340+
headers: { ...corsHeaders(req, config), "Retry-After": "5" },
341+
});
342+
}
343+
const apiAuthError = requireApiAuth(req, config, "data-plane");
344+
if (apiAuthError) return withCors(apiAuthError, req, config);
345+
if (!isAllowedRequestOrigin(req, config)) {
346+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
347+
}
348+
const start = Date.now();
349+
const requestId = nextRequestLogId(start);
350+
const logCtx: RequestLogContext = { model: "web_search", provider: "unknown" };
351+
const response = await handleSearch(req, config, logCtx);
352+
addFinalRequestLog(
353+
requestId,
354+
start,
355+
logCtx,
356+
response.status,
357+
response.status === 499 ? { closeReason: "client_cancel" } : undefined,
358+
);
359+
return withCors(response, req, config);
360+
}
361+
334362
if (url.pathname === "/v1/responses" && req.method === "POST") {
335363
disableResponsesRequestTimeout(req, requestServer);
336364
if (isDraining()) {
@@ -370,7 +398,7 @@ export function startServer(port?: number) {
370398

371399
// Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
372400
// GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
373-
// endpoint clients — alpha/search, memories/*, realtime/* — would surface confusing
401+
// endpoint clients — memories/*, realtime/* — would surface confusing
374402
// serde decode errors instead of a clean not-found).
375403
if (url.pathname.startsWith("/v1/")) {
376404
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);

src/server/search.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* /v1/alpha/search relay.
3+
*
4+
* codex-rs's built-in search client executes CLIENT-SIDE: it POSTs `alpha/search` against the
5+
* configured base_url with the same ChatGPT bearer auth used for model requests. Under Design B
6+
* injection base_url is this proxy, so the request otherwise dies on the /v1/* JSON-404 guard.
7+
* The endpoint is private to the ChatGPT Codex backend, so routed providers and OpenAI API-key
8+
* providers cannot serve it. Relay the JSON request and response verbatim through the configured
9+
* ChatGPT forward provider.
10+
*/
11+
import { formatErrorResponse } from "../bridge";
12+
import {
13+
CodexAccountCooldownError,
14+
CodexAuthContextError,
15+
CodexThreadAffinityExpiredError,
16+
headersForCodexAuthContext,
17+
isCodexAuthContextUsable,
18+
resolveCodexAuthContext,
19+
} from "../codex/auth-context";
20+
import { formatCodexProviderForLog } from "../codex/routing";
21+
import { signalWithTimeout } from "../lib/abort";
22+
import { sidecarEnter } from "../lib/sidecar-tracker";
23+
import type { OcxConfig, OcxProviderConfig } from "../types";
24+
import { isProxyAdmissionSecret } from "./auth-cors";
25+
import { readJsonRequestBody } from "./request-decompress";
26+
import type { RequestLogContext } from "./request-log";
27+
import { codexLogAccountId, decodeRequestErrorResponse, sidecarOutcomeRecorder } from "./responses";
28+
29+
const SEARCH_UPSTREAM_TIMEOUT_MS = 200_000;
30+
const SEARCH_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
31+
32+
interface NamedProvider {
33+
name: string;
34+
provider: OcxProviderConfig;
35+
}
36+
37+
function findSearchUpstream(config: OcxConfig): NamedProvider | undefined {
38+
for (const [name, provider] of Object.entries(config.providers)) {
39+
if (provider.disabled !== true && provider.authMode === "forward") return { name, provider };
40+
}
41+
return undefined;
42+
}
43+
44+
export async function handleSearch(
45+
req: Request,
46+
config: OcxConfig,
47+
logCtx: RequestLogContext,
48+
): Promise<Response> {
49+
let body: unknown;
50+
try {
51+
body = await readJsonRequestBody(req);
52+
} catch (err) {
53+
return decodeRequestErrorResponse(err, "search");
54+
}
55+
const model = (body as { model?: unknown } | null)?.model;
56+
if (typeof model === "string" && model) logCtx.model = model;
57+
58+
const upstream = findSearchUpstream(config);
59+
if (!upstream) {
60+
return formatErrorResponse(
61+
400,
62+
"invalid_request_error",
63+
"Built-in web search needs a ChatGPT forward provider, but none is configured in opencodex. "
64+
+ "Routed and OpenAI API-key providers cannot serve /v1/alpha/search.",
65+
);
66+
}
67+
68+
let authHeaders: Headers;
69+
let recordOutcome: ReturnType<typeof sidecarOutcomeRecorder>;
70+
try {
71+
const authCtx = await resolveCodexAuthContext(req.headers, config);
72+
if (!isCodexAuthContextUsable(authCtx, config)) {
73+
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
74+
}
75+
authHeaders = headersForCodexAuthContext(req.headers, authCtx);
76+
const bearer = authHeaders.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
77+
if (bearer && isProxyAdmissionSecret(bearer, config)) authHeaders.delete("authorization");
78+
if (!authHeaders.get("authorization")) {
79+
return formatErrorResponse(
80+
401,
81+
"authentication_error",
82+
"web search relay needs ChatGPT auth (Authorization header)",
83+
);
84+
}
85+
recordOutcome = sidecarOutcomeRecorder(config, authCtx);
86+
logCtx.provider = formatCodexProviderForLog(upstream.name, codexLogAccountId(authCtx), config);
87+
} catch (err) {
88+
if (err instanceof CodexAccountCooldownError) {
89+
return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
90+
}
91+
if (err instanceof CodexThreadAffinityExpiredError) {
92+
return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
93+
}
94+
if (err instanceof CodexAuthContextError) {
95+
const safeAccountLabel = formatCodexProviderForLog(upstream.name, err.accountId, config);
96+
console.error(`[search] Pool account ${safeAccountLabel} token failed; reauthentication required`);
97+
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
98+
}
99+
throw err;
100+
}
101+
102+
const headers: Record<string, string> = { "content-type": "application/json" };
103+
if (upstream.provider.headers) Object.assign(headers, upstream.provider.headers);
104+
for (const [name, value] of authHeaders) headers[name] = value;
105+
const url = `${upstream.provider.baseUrl}/alpha/search`;
106+
const timeoutMs = config.connectTimeoutMs ?? SEARCH_UPSTREAM_TIMEOUT_MS;
107+
const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
108+
const sidecarExit = sidecarEnter("search");
109+
try {
110+
const upstreamResponse = await fetch(url, {
111+
method: "POST",
112+
headers,
113+
body: JSON.stringify(body),
114+
signal: linkedSignal.signal,
115+
});
116+
const payload = await upstreamResponse.arrayBuffer();
117+
if (payload.byteLength > SEARCH_RESPONSE_MAX_BYTES) {
118+
return formatErrorResponse(502, "upstream_error", `search response too large (${payload.byteLength} bytes)`);
119+
}
120+
recordOutcome?.(upstreamResponse.status);
121+
const relayHeaders: Record<string, string> = {};
122+
const contentType = upstreamResponse.headers.get("content-type");
123+
if (contentType) relayHeaders["content-type"] = contentType;
124+
return new Response(payload, { status: upstreamResponse.status, headers: relayHeaders });
125+
} catch (err) {
126+
if (req.signal.aborted) {
127+
return formatErrorResponse(499, "client_closed_request", "search request canceled by client");
128+
}
129+
if (err instanceof Error && err.name === "TimeoutError") {
130+
recordOutcome?.("timeout");
131+
return formatErrorResponse(504, "upstream_error", "search upstream timed out");
132+
}
133+
recordOutcome?.("connect_error");
134+
return formatErrorResponse(
135+
502,
136+
"upstream_error",
137+
`search relay failed: ${err instanceof Error ? err.message : String(err)}`,
138+
);
139+
} finally {
140+
sidecarExit();
141+
linkedSignal.cleanup();
142+
}
143+
}

tests/server-auth.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,10 +1026,10 @@ describe("server local API auth", () => {
10261026

10271027
const server = startServer(0);
10281028
try {
1029-
// codex-rs endpoint clients (alpha/search, memories/*, realtime/*) must get a clean 404
1029+
// Unsupported codex-rs endpoint clients (memories/*, realtime/*) must get a clean 404
10301030
// instead of a 200 HTML page that fails serde with a confusing decode error.
1031-
// (/v1/images/* is a real relay route now — covered in server-images.test.ts.)
1032-
for (const path of ["/v1/alpha/search", "/v1/realtime/sessions", "/v1/memories/trace_summarize"]) {
1031+
// (/v1/images/* and /v1/alpha/search are real relay routes covered by dedicated tests.)
1032+
for (const path of ["/v1/realtime/sessions", "/v1/memories/trace_summarize"]) {
10331033
const response = await fetch(new URL(path, server.url), { method: "POST" });
10341034
expect(response.status).toBe(404);
10351035
expect(response.headers.get("content-type")).toContain("application/json");

0 commit comments

Comments
 (0)