Fix custom Codex provider base URL routing (#915)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<BuildProviderRegistryOptions, "workspaceGitService">,
|
||||
options?: ProviderClientFactoryOptions,
|
||||
) => AgentClient;
|
||||
|
||||
interface ResolvedProvider {
|
||||
@@ -92,6 +103,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
async function runCustomCodexProviderTurn(
|
||||
providerId: string,
|
||||
baseUrl: string,
|
||||
): Promise<CapturedFakeCodexRecord[]> {
|
||||
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<string, unknown> | 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");
|
||||
|
||||
@@ -174,6 +174,12 @@ interface CodexAppServerClientLike {
|
||||
|
||||
interface CodexAppServerAgentDeps {
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
customProvider?: {
|
||||
id: string;
|
||||
label: string;
|
||||
extends: string;
|
||||
};
|
||||
customCodexConfig?: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> = {
|
||||
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<boolean> {
|
||||
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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user