Skip to content

Commit 2fd3944

Browse files
committed
Merge branch 'pr-134-head' into release-integration-20260320
2 parents a0caf1f + 0adeebd commit 2fd3944

7 files changed

Lines changed: 430 additions & 9 deletions

File tree

index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ import {
138138
type FlaggedAccountMetadataV1,
139139
} from "./lib/storage.js";
140140
import {
141+
applyProxyCompatibleInit,
141142
createCodexHeaders,
142143
extractRequestUrl,
143144
handleErrorResponse,
@@ -1630,11 +1631,11 @@ while (attempted.size < Math.max(1, accountCount)) {
16301631

16311632
try {
16321633
runtimeMetrics.totalRequests++;
1633-
response = await fetch(url, {
1634+
response = await fetch(url, applyProxyCompatibleInit(url, {
16341635
...requestInit,
16351636
headers,
16361637
signal: fetchController.signal,
1637-
});
1638+
}));
16381639
} catch (networkError) {
16391640
const fetchAbortReason = fetchController.signal.reason;
16401641
const isTimeoutAbort =
@@ -2192,11 +2193,11 @@ while (attempted.size < Math.max(1, accountCount)) {
21922193

21932194
try {
21942195
runtimeMetrics.totalRequests++;
2195-
const fallbackResponse = await fetch(url, {
2196+
const fallbackResponse = await fetch(url, applyProxyCompatibleInit(url, {
21962197
...requestInit,
21972198
headers: fallbackHeaders,
21982199
signal: fallbackController.signal,
2199-
});
2200+
}));
22002201
const fallbackSnapshot = readQuotaSchedulerSnapshot(
22012202
fallbackResponse.headers,
22022203
fallbackResponse.status,

lib/request/fetch-helpers.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@
44
*/
55

66
import type { Auth, CodexClient } from "@codex-ai/sdk";
7+
import { ProxyAgent } from "undici";
78
import { queuedRefresh } from "../refresh-queue.js";
89
import { logRequest, logError, logWarn } from "../logger.js";
910
import { getCodexInstructions, getModelFamily } from "../prompts/codex.js";
1011
import { transformRequestBody, normalizeModel } from "./request-transformer.js";
1112
import { convertSseToJson, ensureContentType } from "./response-handler.js";
1213
import type { UserConfig, RequestBody } from "../types.js";
14+
import { registerCleanup } from "../shutdown.js";
1315
import { CodexAuthError } from "../errors.js";
1416
import { isRecord } from "../utils.js";
1517
import {
@@ -59,6 +61,16 @@ const NORMALIZED_UNSUPPORTED_MODEL_PATTERN =
5961
/the model ['"]([^'"]+)['"] is not currently available for this chatgpt account/i;
6062
const MAX_RETRY_DELAY_MS = 5 * 60 * 1000;
6163
const CREATE_CODEX_HEADERS_PARAM_KEYS = new Set(["init", "accountId", "accessToken", "opts"]);
64+
const DEFAULT_PROXY_PORTS: Record<string, number> = {
65+
"http:": 80,
66+
"https:": 443,
67+
};
68+
type ProxyDispatcher = NonNullable<RequestInit["dispatcher"]>;
69+
const sharedProxyDispatchers = new Map<string, ProxyDispatcher>();
70+
71+
type ClosableDispatcher = ProxyDispatcher & {
72+
close?: () => Promise<void> | void;
73+
};
6274

6375
export const DEFAULT_UNSUPPORTED_CODEX_FALLBACK_CHAIN: Record<string, string[]> = {
6476
"gpt-5.3-codex-spark": ["gpt-5-codex", "gpt-5.3-codex", "gpt-5.2-codex"],
@@ -313,6 +325,10 @@ export interface CreateCodexHeadersOptions {
313325
promptCacheKey?: string;
314326
}
315327

328+
export interface ProxyCompatibleRequestInit extends RequestInit {
329+
agent?: unknown;
330+
}
331+
316332
export interface CreateCodexHeadersParams {
317333
init?: RequestInit;
318334
accountId: string;
@@ -418,6 +434,140 @@ export function rewriteUrlForCodex(url: string): string {
418434
return parsedUrl.toString();
419435
}
420436

437+
function hasOwnEnvKey(env: NodeJS.ProcessEnv, key: string): boolean {
438+
return Object.prototype.hasOwnProperty.call(env, key);
439+
}
440+
441+
function resolveProxyEnvValue(
442+
env: NodeJS.ProcessEnv,
443+
lowerKey: string,
444+
upperKey: string,
445+
): string | undefined {
446+
if (hasOwnEnvKey(env, lowerKey)) {
447+
const value = env[lowerKey]?.trim();
448+
return value ? value : undefined;
449+
}
450+
451+
const value = env[upperKey]?.trim();
452+
return value ? value : undefined;
453+
}
454+
455+
function parseNoProxyEntries(noProxyValue: string): Array<{ hostname: string; port: number }> {
456+
return noProxyValue
457+
.split(/[,\s]/)
458+
.map((entry) => entry.trim())
459+
.filter(Boolean)
460+
.map((entry) => {
461+
const parsed = entry.match(/^(.+):(\d+)$/);
462+
const hostname = parsed?.[1] ?? entry;
463+
const portText = parsed?.[2];
464+
return {
465+
hostname: hostname.toLowerCase(),
466+
port: portText ? Number.parseInt(portText, 10) : 0,
467+
};
468+
});
469+
}
470+
471+
function shouldBypassProxyForUrl(url: URL, noProxyValue: string | undefined): boolean {
472+
if (!noProxyValue) return false;
473+
if (noProxyValue === "*") return true;
474+
475+
const hostname = url.host.replace(/:\d*$/, "").toLowerCase();
476+
const port = Number.parseInt(url.port, 10) || DEFAULT_PROXY_PORTS[url.protocol] || 0;
477+
478+
for (const entry of parseNoProxyEntries(noProxyValue)) {
479+
if (entry.hostname === "*") return true;
480+
if (entry.port && entry.port !== port) continue;
481+
482+
if (!/^[.*]/.test(entry.hostname)) {
483+
if (hostname === entry.hostname) {
484+
return true;
485+
}
486+
continue;
487+
}
488+
489+
if (hostname.endsWith(entry.hostname.replace(/^\*/, ""))) {
490+
return true;
491+
}
492+
}
493+
494+
return false;
495+
}
496+
497+
export function resolveProxyUrlForRequest(
498+
url: string,
499+
env: NodeJS.ProcessEnv = process.env,
500+
): string | undefined {
501+
const parsed = new URL(url);
502+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
503+
return undefined;
504+
}
505+
506+
const httpProxy = resolveProxyEnvValue(env, "http_proxy", "HTTP_PROXY");
507+
const httpsProxy = resolveProxyEnvValue(env, "https_proxy", "HTTPS_PROXY");
508+
if (!httpProxy && !httpsProxy) {
509+
return undefined;
510+
}
511+
512+
const noProxy = resolveProxyEnvValue(env, "no_proxy", "NO_PROXY");
513+
if (shouldBypassProxyForUrl(parsed, noProxy)) {
514+
return undefined;
515+
}
516+
517+
return parsed.protocol === "https:"
518+
? (httpsProxy ?? httpProxy)
519+
: httpProxy;
520+
}
521+
522+
function getSharedProxyDispatcher(proxyUrl: string): ProxyDispatcher {
523+
const existing = sharedProxyDispatchers.get(proxyUrl);
524+
if (existing) {
525+
return existing;
526+
}
527+
528+
const dispatcher = new ProxyAgent(proxyUrl) as unknown as ProxyDispatcher;
529+
sharedProxyDispatchers.set(proxyUrl, dispatcher);
530+
return dispatcher;
531+
}
532+
533+
export async function closeSharedProxyDispatchers(): Promise<void> {
534+
while (sharedProxyDispatchers.size > 0) {
535+
const dispatchers = [...sharedProxyDispatchers.values()] as ClosableDispatcher[];
536+
sharedProxyDispatchers.clear();
537+
538+
await Promise.allSettled(
539+
dispatchers.map(async (dispatcher) => {
540+
if (typeof dispatcher.close === "function") {
541+
await dispatcher.close();
542+
}
543+
}),
544+
);
545+
}
546+
}
547+
548+
registerCleanup(closeSharedProxyDispatchers);
549+
550+
export function applyProxyCompatibleInit(
551+
url: string,
552+
init?: ProxyCompatibleRequestInit,
553+
env: NodeJS.ProcessEnv = process.env,
554+
): ProxyCompatibleRequestInit {
555+
const resolvedInit = init ?? {};
556+
if (resolvedInit.dispatcher || resolvedInit.agent) {
557+
return resolvedInit;
558+
}
559+
560+
const proxyUrl = resolveProxyUrlForRequest(url, env);
561+
if (!proxyUrl) {
562+
return resolvedInit;
563+
}
564+
565+
return {
566+
...resolvedInit,
567+
dispatcher: getSharedProxyDispatcher(proxyUrl),
568+
};
569+
}
570+
421571
/**
422572
* Transforms request body and logs the transformation
423573
* Fetches model-specific Codex instructions based on the request model

package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@
9999
"typescript": "^5"
100100
},
101101
"devDependencies": {
102-
"@fast-check/vitest": "^0.2.4",
103102
"@codex-ai/sdk": "file:vendor/codex-ai-sdk",
103+
"@fast-check/vitest": "^0.2.4",
104104
"@types/node": "^25.3.0",
105105
"@typescript-eslint/eslint-plugin": "^8.56.0",
106106
"@typescript-eslint/parser": "^8.56.0",
@@ -115,9 +115,10 @@
115115
"vitest": "^4.0.18"
116116
},
117117
"dependencies": {
118-
"@openauthjs/openauth": "^0.4.3",
119118
"@codex-ai/plugin": "file:vendor/codex-ai-plugin",
119+
"@openauthjs/openauth": "^0.4.3",
120120
"hono": "4.12.6",
121+
"undici": "^6.24.1",
121122
"zod": "^4.3.6"
122123
},
123124
"overrides": {

0 commit comments

Comments
 (0)