From d32462e9ee25fd07a8ba0cbe9f2d8a81c4d5f668 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 12 May 2026 11:54:15 +0800 Subject: [PATCH] Fix custom Codex provider base URL routing (#915) --- docs/custom-providers.md | 24 +++ .../src/server/agent/provider-registry.ts | 32 +++- .../providers/codex-app-server-agent.test.ts | 138 +++++++++++++++++- .../agent/providers/codex-app-server-agent.ts | 67 ++++++++- 4 files changed, 252 insertions(+), 9 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index a5a9903d4..c869459b1 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -59,6 +59,30 @@ Required fields for custom providers: - `extends` — which built-in provider to inherit from (or `"acp"`) - `label` — display name in the UI +### Codex with an OpenAI-compatible endpoint + +Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`. + +```json +{ + "agents": { + "providers": { + "my-codex": { + "extends": "codex", + "label": "My Codex", + "env": { + "OPENAI_API_KEY": "sk-...", + "OPENAI_BASE_URL": "https://custom-relay.example.com" + }, + "models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }] + } + } + } +} +``` + +If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is. + --- ## Z.AI (Zhipu) coding plan diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 51370a130..4da1d875a 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -67,10 +67,21 @@ export interface BuildProviderRegistryOptions { isDev?: boolean; } +interface ProviderClientFactoryOptions extends Pick< + BuildProviderRegistryOptions, + "workspaceGitService" +> { + customProvider?: { + id: string; + label: string; + extends: string; + }; +} + type ProviderClientFactory = ( logger: Logger, runtimeSettings?: ProviderRuntimeSettings, - options?: Pick, + options?: ProviderClientFactoryOptions, ) => AgentClient; interface ResolvedProvider { @@ -92,6 +103,7 @@ const PROVIDER_CLIENT_FACTORIES: Record = { codex: (logger, runtimeSettings, options) => new CodexAppServerAgentClient(logger, runtimeSettings, { workspaceGitService: options?.workspaceGitService, + customProvider: options?.customProvider, }), copilot: (logger, runtimeSettings) => new CopilotACPAgentClient({ @@ -510,10 +522,11 @@ function addDerivedProviders( continue; } - const baseProvider = resolvedProviders.get(override.extends); + const baseProviderId = override.extends; + const baseProvider = resolvedProviders.get(baseProviderId); if (!baseProvider) { throw new Error( - `Custom provider '${providerId}' extends unknown provider '${override.extends}'`, + `Custom provider '${providerId}' extends unknown provider '${baseProviderId}'`, ); } @@ -522,7 +535,7 @@ function addDerivedProviders( toRuntimeSettings(override), ); const baseDefinition = baseProvider.definition; - const baseFactory = getProviderClientFactory(override.extends); + const baseFactory = getProviderClientFactory(baseProviderId); resolvedProviders.set(providerId, { definition: createDerivedDefinition(providerId, baseDefinition, override), @@ -530,8 +543,15 @@ function addDerivedProviders( profileModels: override.models ?? [], additionalModels: override.additionalModels ?? [], enabled: override.enabled !== false, - derivedFromProviderId: override.extends, - createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings), + derivedFromProviderId: baseProviderId, + createBaseClient: (logger) => + baseFactory(logger, mergedRuntimeSettings, { + customProvider: { + id: providerId, + label: override.label ?? providerId, + extends: baseProviderId, + }, + }), }); } } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index b302526cc..b282e409d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { EventEmitter } from "node:events"; -import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -24,6 +24,7 @@ import { } from "./codex/test-utils/fake-app-server.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js"; +import { buildProviderRegistry } from "../provider-registry.js"; interface CollaborationModeRecord { name: string; @@ -102,6 +103,100 @@ function markdownImageSource(markdown: string): string { return match[1].replace(/\\\)/g, ")"); } +type CapturedFakeCodexRecord = Record; + +async function runCustomCodexProviderTurn( + providerId: string, + baseUrl: string, +): Promise { + const tempDir = await mkdtemp(path.join(tmpdir(), "codex-custom-provider-")); + const fakeAppServerPath = path.join(tempDir, "fake-codex-app-server.cjs"); + const capturedRequestsPath = path.join(tempDir, "requests.jsonl"); + writeFileSync( + fakeAppServerPath, + ` +const fs = require("node:fs"); + +const capturePath = process.env.PASEO_FAKE_CODEX_CAPTURE; +let buffer = ""; + +fs.appendFileSync(capturePath, JSON.stringify({ + kind: "env", + OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, + OPENAI_API_KEY: process.env.OPENAI_API_KEY, +}) + "\\n"); + +function record(method, params) { + fs.appendFileSync(capturePath, JSON.stringify({ kind: "request", method, params }) + "\\n"); +} + +function resultFor(method) { + if (method === "initialize") return {}; + if (method === "collaborationMode/list") return { data: [] }; + if (method === "skills/list") return { data: [] }; + if (method === "config/read") return { config: {} }; + if (method === "getUserSavedConfig") return { config: {} }; + if (method === "model/list") return { data: [{ id: "custom-model", isDefault: true }] }; + if (method === "thread/start") return { thread: { id: "thread-1" } }; + if (method === "turn/start") return {}; + return {}; +} + +process.stdin.on("data", (chunk) => { + buffer += chunk.toString(); + for (;;) { + const newlineIndex = buffer.indexOf("\\n"); + if (newlineIndex === -1) break; + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if (!line) continue; + const message = JSON.parse(line); + record(message.method, message.params); + process.stdout.write(JSON.stringify({ id: message.id, result: resultFor(message.method) }) + "\\n"); + } +}); +`, + ); + + const registry = buildProviderRegistry(createTestLogger(), { + providerOverrides: { + [providerId]: { + extends: "codex", + label: "Custom Codex", + command: [process.execPath, fakeAppServerPath], + env: { + OPENAI_API_KEY: "sk-custom", + OPENAI_BASE_URL: baseUrl, + PASEO_FAKE_CODEX_CAPTURE: capturedRequestsPath, + }, + }, + }, + }); + const session = await registry[providerId].createClient(createTestLogger()).createSession({ + provider: providerId, + cwd: "/workspace/project", + modeId: "auto", + model: "custom-model", + }); + + try { + await session.startTurn("use the custom endpoint"); + return readFileSync(capturedRequestsPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as CapturedFakeCodexRecord); + } finally { + await session.close(); + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown { + const threadStart = records.find((record) => record.method === "thread/start"); + const params = threadStart?.params as Record | undefined; + return params?.config; +} + describe("Codex app-server provider", () => { test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => { const requests: Array<{ method: string; params: unknown }> = []; @@ -242,6 +337,47 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("configures Codex app-server to use a custom provider base URL", async () => { + const capturedRequests = await runCustomCodexProviderTurn( + "codex-iisb", + "https://custom-relay.example.com", + ); + + expect(capturedRequests[0]).toEqual({ + kind: "env", + OPENAI_API_KEY: "sk-custom", + OPENAI_BASE_URL: "https://custom-relay.example.com", + }); + expect(capturedThreadStartConfig(capturedRequests)).toEqual({ + model_provider: "codex-iisb", + model_providers: { + "codex-iisb": { + name: "Custom Codex", + base_url: "https://custom-relay.example.com/v1", + env_key: "OPENAI_API_KEY", + requires_openai_auth: false, + wire_api: "responses", + }, + }, + }); + }); + + test("does not append v1 twice for custom Codex provider base URLs", async () => { + const capturedRequests = await runCustomCodexProviderTurn( + "codex-custom", + "https://custom-relay.example.com/v1/", + ); + + expect(capturedThreadStartConfig(capturedRequests)).toEqual({ + model_provider: "codex-custom", + model_providers: { + "codex-custom": expect.objectContaining({ + base_url: "https://custom-relay.example.com/v1", + }), + }, + }); + }); + test("lists repo skills using WorkspaceGitService repo-root resolution", async () => { const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-")); const cwd = path.join(tempDir, "repo", "packages", "app"); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 555cb28bf..9154898e1 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -174,6 +174,12 @@ interface CodexAppServerClientLike { interface CodexAppServerAgentDeps { workspaceGitService?: Pick; + customProvider?: { + id: string; + label: string; + extends: string; + }; + customCodexConfig?: Record | null; _createCodexClient?: ( child: ChildProcessWithoutNullStreams, logger: Logger, @@ -2525,6 +2531,50 @@ function buildCodexAppServerInitializeParams(): { }; } +function normalizeOpenAICompatibleBaseUrl(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const withoutTrailingSlashes = trimmed.replace(/\/+$/u, ""); + if (withoutTrailingSlashes.endsWith("/v1")) { + return withoutTrailingSlashes; + } + return `${withoutTrailingSlashes}/v1`; +} + +function buildCodexCustomProviderConfig( + runtimeSettings: ProviderRuntimeSettings | undefined, + customProvider: CodexAppServerAgentDeps["customProvider"], +): Record | null { + if (customProvider?.extends !== CODEX_PROVIDER) { + return null; + } + const baseUrl = runtimeSettings?.env?.OPENAI_BASE_URL; + if (typeof baseUrl !== "string") { + return null; + } + const normalizedBaseUrl = normalizeOpenAICompatibleBaseUrl(baseUrl); + if (!normalizedBaseUrl) { + return null; + } + const providerConfig: Record = { + name: customProvider.label, + base_url: normalizedBaseUrl, + wire_api: "responses", + }; + if (runtimeSettings?.env?.OPENAI_API_KEY?.trim()) { + providerConfig.env_key = "OPENAI_API_KEY"; + providerConfig.requires_openai_auth = false; + } + return { + model_provider: customProvider.id, + model_providers: { + [customProvider.id]: providerConfig, + }, + }; +} + interface CodexSubAgentCallState { callId: string; toolCall: ToolCallTimelineItem; @@ -3587,6 +3637,9 @@ class CodexAppServerAgentSession implements AgentSession { if (this.config.extra?.codex) { Object.assign(innerConfig, this.config.extra.codex); } + if (this.deps.customCodexConfig) { + Object.assign(innerConfig, this.deps.customCodexConfig); + } return Object.keys(innerConfig).length > 0 ? innerConfig : null; } @@ -4528,6 +4581,16 @@ export class CodexAppServerAgentClient implements AgentClient { private readonly deps: CodexAppServerAgentDeps = {}, ) {} + private sessionDeps(): CodexAppServerAgentDeps { + return { + ...this.deps, + customCodexConfig: buildCodexCustomProviderConfig( + this.runtimeSettings, + this.deps.customProvider, + ), + }; + } + private resolveGoalsEnabled(): Promise { if (!this.goalsEnabledPromise) { this.goalsEnabledPromise = (async () => { @@ -4593,7 +4656,7 @@ export class CodexAppServerAgentClient implements AgentClient { null, this.logger, () => this.spawnAppServer(launchContext?.env, { goalsEnabled }), - this.deps, + this.sessionDeps(), options?.persistSession === false, goalsEnabled, ); @@ -4619,7 +4682,7 @@ export class CodexAppServerAgentClient implements AgentClient { handle, this.logger, () => this.spawnAppServer(launchContext?.env, { goalsEnabled }), - this.deps, + this.sessionDeps(), false, goalsEnabled, );