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"`)
|
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||||
- `label` — display name in the UI
|
- `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
|
## Z.AI (Zhipu) coding plan
|
||||||
|
|||||||
@@ -67,10 +67,21 @@ export interface BuildProviderRegistryOptions {
|
|||||||
isDev?: boolean;
|
isDev?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderClientFactoryOptions extends Pick<
|
||||||
|
BuildProviderRegistryOptions,
|
||||||
|
"workspaceGitService"
|
||||||
|
> {
|
||||||
|
customProvider?: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
extends: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
type ProviderClientFactory = (
|
type ProviderClientFactory = (
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
options?: Pick<BuildProviderRegistryOptions, "workspaceGitService">,
|
options?: ProviderClientFactoryOptions,
|
||||||
) => AgentClient;
|
) => AgentClient;
|
||||||
|
|
||||||
interface ResolvedProvider {
|
interface ResolvedProvider {
|
||||||
@@ -92,6 +103,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
|||||||
codex: (logger, runtimeSettings, options) =>
|
codex: (logger, runtimeSettings, options) =>
|
||||||
new CodexAppServerAgentClient(logger, runtimeSettings, {
|
new CodexAppServerAgentClient(logger, runtimeSettings, {
|
||||||
workspaceGitService: options?.workspaceGitService,
|
workspaceGitService: options?.workspaceGitService,
|
||||||
|
customProvider: options?.customProvider,
|
||||||
}),
|
}),
|
||||||
copilot: (logger, runtimeSettings) =>
|
copilot: (logger, runtimeSettings) =>
|
||||||
new CopilotACPAgentClient({
|
new CopilotACPAgentClient({
|
||||||
@@ -510,10 +522,11 @@ function addDerivedProviders(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseProvider = resolvedProviders.get(override.extends);
|
const baseProviderId = override.extends;
|
||||||
|
const baseProvider = resolvedProviders.get(baseProviderId);
|
||||||
if (!baseProvider) {
|
if (!baseProvider) {
|
||||||
throw new Error(
|
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),
|
toRuntimeSettings(override),
|
||||||
);
|
);
|
||||||
const baseDefinition = baseProvider.definition;
|
const baseDefinition = baseProvider.definition;
|
||||||
const baseFactory = getProviderClientFactory(override.extends);
|
const baseFactory = getProviderClientFactory(baseProviderId);
|
||||||
|
|
||||||
resolvedProviders.set(providerId, {
|
resolvedProviders.set(providerId, {
|
||||||
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
definition: createDerivedDefinition(providerId, baseDefinition, override),
|
||||||
@@ -530,8 +543,15 @@ function addDerivedProviders(
|
|||||||
profileModels: override.models ?? [],
|
profileModels: override.models ?? [],
|
||||||
additionalModels: override.additionalModels ?? [],
|
additionalModels: override.additionalModels ?? [],
|
||||||
enabled: override.enabled !== false,
|
enabled: override.enabled !== false,
|
||||||
derivedFromProviderId: override.extends,
|
derivedFromProviderId: baseProviderId,
|
||||||
createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings),
|
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 { describe, expect, test, vi } from "vitest";
|
||||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { EventEmitter } from "node:events";
|
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 { mkdtemp } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from "./codex/test-utils/fake-app-server.js";
|
} from "./codex/test-utils/fake-app-server.js";
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
||||||
|
import { buildProviderRegistry } from "../provider-registry.js";
|
||||||
|
|
||||||
interface CollaborationModeRecord {
|
interface CollaborationModeRecord {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -102,6 +103,100 @@ function markdownImageSource(markdown: string): string {
|
|||||||
return match[1].replace(/\\\)/g, ")");
|
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", () => {
|
describe("Codex app-server provider", () => {
|
||||||
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
test("passes ephemeral: true to thread/start when constructed as ephemeral", async () => {
|
||||||
const requests: Array<{ method: string; params: unknown }> = [];
|
const requests: Array<{ method: string; params: unknown }> = [];
|
||||||
@@ -242,6 +337,47 @@ describe("Codex app-server provider", () => {
|
|||||||
await session.close();
|
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 () => {
|
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
|
||||||
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
|
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
|
||||||
const cwd = path.join(tempDir, "repo", "packages", "app");
|
const cwd = path.join(tempDir, "repo", "packages", "app");
|
||||||
|
|||||||
@@ -174,6 +174,12 @@ interface CodexAppServerClientLike {
|
|||||||
|
|
||||||
interface CodexAppServerAgentDeps {
|
interface CodexAppServerAgentDeps {
|
||||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||||
|
customProvider?: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
extends: string;
|
||||||
|
};
|
||||||
|
customCodexConfig?: Record<string, unknown> | null;
|
||||||
_createCodexClient?: (
|
_createCodexClient?: (
|
||||||
child: ChildProcessWithoutNullStreams,
|
child: ChildProcessWithoutNullStreams,
|
||||||
logger: Logger,
|
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 {
|
interface CodexSubAgentCallState {
|
||||||
callId: string;
|
callId: string;
|
||||||
toolCall: ToolCallTimelineItem;
|
toolCall: ToolCallTimelineItem;
|
||||||
@@ -3587,6 +3637,9 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (this.config.extra?.codex) {
|
if (this.config.extra?.codex) {
|
||||||
Object.assign(innerConfig, 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;
|
return Object.keys(innerConfig).length > 0 ? innerConfig : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4528,6 +4581,16 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
private readonly deps: CodexAppServerAgentDeps = {},
|
private readonly deps: CodexAppServerAgentDeps = {},
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private sessionDeps(): CodexAppServerAgentDeps {
|
||||||
|
return {
|
||||||
|
...this.deps,
|
||||||
|
customCodexConfig: buildCodexCustomProviderConfig(
|
||||||
|
this.runtimeSettings,
|
||||||
|
this.deps.customProvider,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private resolveGoalsEnabled(): Promise<boolean> {
|
private resolveGoalsEnabled(): Promise<boolean> {
|
||||||
if (!this.goalsEnabledPromise) {
|
if (!this.goalsEnabledPromise) {
|
||||||
this.goalsEnabledPromise = (async () => {
|
this.goalsEnabledPromise = (async () => {
|
||||||
@@ -4593,7 +4656,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
null,
|
null,
|
||||||
this.logger,
|
this.logger,
|
||||||
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
||||||
this.deps,
|
this.sessionDeps(),
|
||||||
options?.persistSession === false,
|
options?.persistSession === false,
|
||||||
goalsEnabled,
|
goalsEnabled,
|
||||||
);
|
);
|
||||||
@@ -4619,7 +4682,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
handle,
|
handle,
|
||||||
this.logger,
|
this.logger,
|
||||||
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
() => this.spawnAppServer(launchContext?.env, { goalsEnabled }),
|
||||||
this.deps,
|
this.sessionDeps(),
|
||||||
false,
|
false,
|
||||||
goalsEnabled,
|
goalsEnabled,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user