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
26 changes: 26 additions & 0 deletions src/proxy/fetch-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ProxyAgent, type Dispatcher } from "undici";
import { getConfig } from "../config.js";

let cachedProxyUrl: string | null = null;
let cachedDispatcher: Dispatcher | undefined;

export function getFetchDispatcher(): Dispatcher | undefined {
let proxyUrl: string | null = null;
try {
proxyUrl = getConfig().tls.proxy_url;
} catch {
proxyUrl = process.env.HTTPS_PROXY ?? process.env.https_proxy ?? null;
}

if (!proxyUrl) return undefined;
if (proxyUrl === cachedProxyUrl && cachedDispatcher) return cachedDispatcher;

cachedProxyUrl = proxyUrl;
cachedDispatcher = new ProxyAgent(proxyUrl);
return cachedDispatcher;
}

export function withFetchDispatcher(init: RequestInit): RequestInit & { dispatcher?: Dispatcher } {
const dispatcher = getFetchDispatcher();
return dispatcher ? { ...init, dispatcher } : init;
}
5 changes: 3 additions & 2 deletions src/proxy/gemini-upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { CodexResponsesRequest, CodexSSEEvent } from "./codex-types.js";
import { CodexApiError } from "./codex-types.js";
import { parseSSEStream } from "./codex-sse.js";
import { translateCodexToGeminiRequest } from "../translation/codex-request-to-gemini.js";
import { withFetchDispatcher } from "./fetch-dispatcher.js";

function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
Expand Down Expand Up @@ -46,15 +47,15 @@ export class GeminiUpstream implements UpstreamAdapter {
// Always use streaming endpoint; non-streaming requests also use it for simplicity
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelId)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(this.apiKey)}`;

const response = await fetch(url, {
const response = await fetch(url, withFetchDispatcher({
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
body: JSON.stringify(body),
signal,
});
}));

if (!response.ok) {
const errorText = await response.text().catch(() => `HTTP ${response.status}`);
Expand Down
5 changes: 3 additions & 2 deletions src/proxy/openai-upstream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { CodexResponsesRequest, CodexSSEEvent } from "./codex-types.js";
import { CodexApiError } from "./codex-types.js";
import { parseSSEStream } from "./codex-sse.js";
import { translateCodexToOpenAIRequest } from "../translation/codex-request-to-openai.js";
import { withFetchDispatcher } from "./fetch-dispatcher.js";

function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
Expand Down Expand Up @@ -41,7 +42,7 @@ export class OpenAIUpstream implements UpstreamAdapter {
const modelId = extractModelId(req.model);
const body = translateCodexToOpenAIRequest(req, modelId, req.stream);

const response = await fetch(`${this.baseUrl}/chat/completions`, {
const response = await fetch(`${this.baseUrl}/chat/completions`, withFetchDispatcher({
method: "POST",
headers: {
"Authorization": `Bearer ${this.apiKey}`,
Expand All @@ -50,7 +51,7 @@ export class OpenAIUpstream implements UpstreamAdapter {
},
body: JSON.stringify(body),
signal,
});
}));

if (!response.ok) {
const errorText = await response.text().catch(() => `HTTP ${response.status}`);
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/proxy/fetch-dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockGetConfig = vi.fn();
const mockProxyAgent = vi.fn((url: string) => ({ proxyUrl: url }));

vi.mock("@src/config.js", () => ({
getConfig: () => mockGetConfig(),
}));

vi.mock("undici", () => ({
ProxyAgent: mockProxyAgent,
}));

describe("fetch dispatcher", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
delete process.env.HTTPS_PROXY;
delete process.env.https_proxy;
});

it("does not add a dispatcher when no proxy is configured", async () => {
mockGetConfig.mockReturnValue({ tls: { proxy_url: null } });
const { withFetchDispatcher } = await import("@src/proxy/fetch-dispatcher.js");

const init = { method: "POST" };

expect(withFetchDispatcher(init)).toBe(init);
expect(mockProxyAgent).not.toHaveBeenCalled();
});

it("adds a ProxyAgent dispatcher from tls.proxy_url", async () => {
mockGetConfig.mockReturnValue({ tls: { proxy_url: "http://127.0.0.1:7890" } });
const { withFetchDispatcher } = await import("@src/proxy/fetch-dispatcher.js");

const init = { method: "POST" };
const result = withFetchDispatcher(init);

expect(result).not.toBe(init);
expect(result.dispatcher).toEqual({ proxyUrl: "http://127.0.0.1:7890" });
expect(mockProxyAgent).toHaveBeenCalledWith("http://127.0.0.1:7890");
});

it("falls back to HTTPS_PROXY before config is loaded", async () => {
mockGetConfig.mockImplementation(() => {
throw new Error("Config not loaded");
});
process.env.HTTPS_PROXY = "http://127.0.0.1:7891";
const { withFetchDispatcher } = await import("@src/proxy/fetch-dispatcher.js");

const result = withFetchDispatcher({ method: "POST" });

expect(result.dispatcher).toEqual({ proxyUrl: "http://127.0.0.1:7891" });
expect(mockProxyAgent).toHaveBeenCalledWith("http://127.0.0.1:7891");
});
});
Loading