|
4 | 4 | */ |
5 | 5 |
|
6 | 6 | import type { Auth, CodexClient } from "@codex-ai/sdk"; |
| 7 | +import { ProxyAgent } from "undici"; |
7 | 8 | import { queuedRefresh } from "../refresh-queue.js"; |
8 | 9 | import { logRequest, logError, logWarn } from "../logger.js"; |
9 | 10 | import { getCodexInstructions, getModelFamily } from "../prompts/codex.js"; |
10 | 11 | import { transformRequestBody, normalizeModel } from "./request-transformer.js"; |
11 | 12 | import { convertSseToJson, ensureContentType } from "./response-handler.js"; |
12 | 13 | import type { UserConfig, RequestBody } from "../types.js"; |
| 14 | +import { registerCleanup } from "../shutdown.js"; |
13 | 15 | import { CodexAuthError } from "../errors.js"; |
14 | 16 | import { isRecord } from "../utils.js"; |
15 | 17 | import { |
@@ -59,6 +61,16 @@ const NORMALIZED_UNSUPPORTED_MODEL_PATTERN = |
59 | 61 | /the model ['"]([^'"]+)['"] is not currently available for this chatgpt account/i; |
60 | 62 | const MAX_RETRY_DELAY_MS = 5 * 60 * 1000; |
61 | 63 | 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 | +}; |
62 | 74 |
|
63 | 75 | export const DEFAULT_UNSUPPORTED_CODEX_FALLBACK_CHAIN: Record<string, string[]> = { |
64 | 76 | "gpt-5.3-codex-spark": ["gpt-5-codex", "gpt-5.3-codex", "gpt-5.2-codex"], |
@@ -313,6 +325,10 @@ export interface CreateCodexHeadersOptions { |
313 | 325 | promptCacheKey?: string; |
314 | 326 | } |
315 | 327 |
|
| 328 | +export interface ProxyCompatibleRequestInit extends RequestInit { |
| 329 | + agent?: unknown; |
| 330 | +} |
| 331 | + |
316 | 332 | export interface CreateCodexHeadersParams { |
317 | 333 | init?: RequestInit; |
318 | 334 | accountId: string; |
@@ -418,6 +434,140 @@ export function rewriteUrlForCodex(url: string): string { |
418 | 434 | return parsedUrl.toString(); |
419 | 435 | } |
420 | 436 |
|
| 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 | + |
421 | 571 | /** |
422 | 572 | * Transforms request body and logs the transformation |
423 | 573 | * Fetches model-specific Codex instructions based on the request model |
|
0 commit comments