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 src/domain/models/models-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const CODEX_ALLOWED_OPENAI_MODEL_IDS = new Set([
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.5",
Expand Down
50 changes: 48 additions & 2 deletions src/http/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
isTokenUsagePopulated,
type TokenUsage,
} from "../../usage/token-usage";
import { isObjectRecord } from "../../utils/object";
import { isObjectRecord, readBooleanField } from "../../utils/object";
import {
parseModelForProxyRoute,
proxyRouteTable,
Expand All @@ -35,6 +35,31 @@ const proxyErrorResponse = (message: string, type = "proxy_error") => ({
},
});

const CODEX_SSE_HEADER_TIMEOUT_MS = 10_000;

const createCodexSseHeaderTimeout = (): {
signal: AbortSignal;
clear(): void;
error(): Error | undefined;
} => {
const controller = new AbortController();
let error: Error | undefined;
const timeout = setTimeout(() => {
error = new Error(
`Codex SSE response headers timed out after ${CODEX_SSE_HEADER_TIMEOUT_MS}ms`
);
controller.abort(error);
}, CODEX_SSE_HEADER_TIMEOUT_MS);

return {
signal: controller.signal,
clear(): void {
clearTimeout(timeout);
},
error: () => error,
};
};

const removeProxyAuthHeaders = (headers: Headers): void => {
headers.delete("authorization");
headers.delete("x-api-key");
Expand Down Expand Up @@ -222,6 +247,7 @@ const proxyRequest = async (
let upstreamUrl = "";
let responseTransformer: ((response: Response) => Promise<Response>) | null =
null;
let useCodexSseHeaderTimeout = false;

switch (route.provider) {
case "codex": {
Expand All @@ -246,6 +272,8 @@ const proxyRequest = async (
upstreamUrl = codexProxy.upstreamUrl;
requestBody = codexProxy.bodyText;
responseTransformer = codexProxy.transformResponse;
useCodexSseHeaderTimeout =
readBooleanField(codexProxy.bodyJson, "stream") === true;

const webSocketResponse = await tryProxyCodexWebSocket({
headers,
Expand Down Expand Up @@ -312,14 +340,32 @@ const proxyRequest = async (

let upstreamResponse: Response;
try {
const headerTimeout = useCodexSseHeaderTimeout
? createCodexSseHeaderTimeout()
: null;
const upstreamRequestInit: BunFetchRequestInit = {
method: context.req.method,
headers,
body: requestBody,
// Provider streams can pause for minutes while a model is thinking.
timeout: false,
};
upstreamResponse = await fetch(upstreamUrl, upstreamRequestInit);
if (headerTimeout) {
upstreamRequestInit.signal = AbortSignal.any([
context.req.raw.signal,
headerTimeout.signal,
]);
}
try {
upstreamResponse = await fetch(upstreamUrl, upstreamRequestInit);
} catch (error) {
const timeoutError = headerTimeout?.error();
throw timeoutError && !context.req.raw.signal.aborted
? timeoutError
: error;
} finally {
headerTimeout?.clear();
}
} catch (error) {
usageRecorder.recordImmediate(500);
throw error;
Expand Down
55 changes: 53 additions & 2 deletions src/providers/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const CODEX_DEVICE_USER_CODE_URL = `${CODEX_ISSUER}/api/accounts/deviceauth/user
const CODEX_DEVICE_TOKEN_URL = `${CODEX_ISSUER}/api/accounts/deviceauth/token`;
const CODEX_OAUTH_STATE_TTL_MS = 15 * 60 * 1000;
const CODEX_POLLING_SAFETY_MARGIN_MS = 3000;
const CODEX_SLOW_DOWN_INCREMENT_MS = 5000;

const codexStartOptionsSchema = z.object({
mode: z.enum(["browser", "headless"]).optional(),
Expand Down Expand Up @@ -84,6 +85,10 @@ type CodexDeviceTokenResponse = {
code_verifier?: string;
};

type CodexDeviceTokenErrorResponse = {
error?: string | { code?: string };
};

const resolveCodexOAuthMode = (
options: Record<string, unknown> | undefined
): "browser" | "headless" => {
Expand Down Expand Up @@ -172,6 +177,42 @@ const parseTokenResponse = async (
return body;
};

const readCodexDeviceTokenErrorCode = async (
response: Response
): Promise<string | null> => {
const text = await response
.clone()
.text()
.catch(() => "");
if (!text) {
return null;
}

try {
const body = JSON.parse(text) as CodexDeviceTokenErrorResponse;
const error = body.error;
if (typeof error === "string") {
return error;
}
return typeof error?.code === "string" ? error.code : null;
} catch {
return null;
}
};

const waitForCodexDevicePoll = async (input: {
intervalMs: number;
expiresAt: number;
}): Promise<void> => {
const remainingMs = input.expiresAt - Date.now();
if (remainingMs <= 0) {
return;
}
await sleep(
Math.min(input.intervalMs + CODEX_POLLING_SAFETY_MARGIN_MS, remainingMs)
);
};

const exchangeAuthorizationCodeForTokens = async (input: {
code: string;
redirectUri: string;
Expand Down Expand Up @@ -238,6 +279,7 @@ const pollDeviceAuthorizationCode = async (input: {
intervalMs: number;
expiresAt: number;
}): Promise<{ authorizationCode: string; codeVerifier: string }> => {
let intervalMs = input.intervalMs;
while (Date.now() < input.expiresAt) {
const response = await fetch(CODEX_DEVICE_TOKEN_URL, {
method: "POST",
Expand All @@ -262,8 +304,17 @@ const pollDeviceAuthorizationCode = async (input: {
};
}

if (response.status === 403 || response.status === 404) {
await sleep(input.intervalMs + CODEX_POLLING_SAFETY_MARGIN_MS);
const errorCode = await readCodexDeviceTokenErrorCode(response);
if (errorCode === "slow_down") {
intervalMs += CODEX_SLOW_DOWN_INCREMENT_MS;
}
if (
errorCode === "slow_down" ||
errorCode === "deviceauth_authorization_pending" ||
response.status === 403 ||
response.status === 404
) {
await waitForCodexDevicePoll({ intervalMs, expiresAt: input.expiresAt });
continue;
}

Expand Down
1 change: 1 addition & 0 deletions src/providers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const CODEX_RESPONSE_ENDPOINT =
export const CODEX_WEBSOCKET_BETA_HEADER = "responses_websockets=2026-02-06";
// https://github.com/anomalyco/opencode/blob/d848c9b6a32f408e8b9bf6448b83af05629454d0/packages/opencode/src/plugin/codex.ts#L619
export const CODEX_ORIGINATOR = "opencode";
export const CODEX_USER_AGENT = "opencode";

// https://github.com/anomalyco/opencode/blob/d848c9b6a32f408e8b9bf6448b83af05629454d0/packages/opencode/src/plugin/copilot.ts#L121-L131
// https://github.com/badlogic/pi-mono/blob/5c0ec26c28c918c5301f218e8c13fcc540d8e3a4/packages/ai/src/providers/github-copilot-headers.ts#L27-L34
Expand Down
10 changes: 9 additions & 1 deletion src/providers/proxies/codex-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
CODEX_ACCOUNT_ID_HEADER,
CODEX_ORIGINATOR,
CODEX_RESPONSE_ENDPOINT,
CODEX_USER_AGENT,
} from "../constants";

const trimString = (value: unknown): string =>
Expand Down Expand Up @@ -78,7 +79,8 @@ export const transformCodexBodyJson = (
// https://github.com/anomalyco/opencode/blob/d848c9b6a32f408e8b9bf6448b83af05629454d0/packages/opencode/src/session/llm.ts#L65-L112
// - Codex-native clients include `instructions` explicitly in the request body.
// https://github.com/badlogic/pi-mono/blob/5c0ec26c28c918c5301f218e8c13fcc540d8e3a4/packages/ai/src/providers/openai-codex-responses.ts#L286-L291
// - Codex-native clients also omit `max_output_tokens` / `max_completion_tokens`.
// - Codex-native clients also omit `max_output_tokens` / `max_completion_tokens`
// and force `store: false`.
// https://github.com/badlogic/pi-mono/blob/5c0ec26c28c918c5301f218e8c13fcc540d8e3a4/packages/ai/src/providers/openai-codex-responses.ts#L286-L315
const {
max_output_tokens: _maxOutputTokens,
Expand All @@ -96,6 +98,7 @@ export const transformCodexBodyJson = (
return {
...nextBody,
instructions,
store: false,
...(sessionId ? { prompt_cache_key: sessionId } : {}),
};
};
Expand Down Expand Up @@ -139,6 +142,11 @@ export const prepareCodexProxyRequest = (
const bodyJson = transformCodexBodyJson(input.bodyJson, input.sessionId);

input.headers.set("authorization", `Bearer ${input.accessToken}`);
input.headers.set("content-type", "application/json");
input.headers.set("User-Agent", CODEX_USER_AGENT);
if (isStreamingRequest) {
input.headers.set("accept", "text/event-stream");
}
if (!input.headers.get("originator")) {
input.headers.set("originator", CODEX_ORIGINATOR);
}
Expand Down
Loading