From 9b3f1bfffdce88fb00f24e0b12a5a476fdac2944 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 05:02:14 +0000 Subject: [PATCH] Start custom Codex providers with reserved names (#6954) * fix(codex): start custom providers with reserved names Codex rejects custom provider keys such as ollama before a turn starts. Namespace the generated keys without changing user-facing provider ids. Keep older sessions discoverable and select the new key explicitly when resuming them, so the startup fix does not strand existing conversations. Closes #6874 Co-authored-by: Egg3901 * fix(codex): keep native sessions out of custom provider imports * fix(providers): align custom provider example with generated config Correct the documented internal provider key and simplify import filtering while preserving native and legacy session selection. * fix(codex): exclude native GCP sessions from custom imports * test(codex): seed sessions through the portable launcher --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Egg3901 --- docs/custom-providers.md | 6 +- .../providers/codex-app-server-agent.test.ts | 69 ++++++++- .../agent/providers/codex-app-server-agent.ts | 34 ++++- ...x-custom-provider-import.local.e2e.test.ts | 143 ++++++++++++------ 4 files changed, 195 insertions(+), 57 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index a6c6cefbdc..615389fe60 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -232,7 +232,7 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. -Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. +Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone. The generated Codex provider key is `paseo:`, so a Paseo provider such as `ollama` does not override a Codex built-in. Existing custom sessions created with a non-built-in provider key remain available in Import session. Native Codex conversations using a built-in key stay separate from a custom provider of the same name. ### Setup @@ -260,9 +260,9 @@ Paseo passes those variables through to the Codex app-server process **and** map Under the hood, for each custom Codex provider Paseo injects this into Codex's config: ```toml -model_provider = "my-codex" +model_provider = "paseo:my-codex" -[model_providers.my-codex] +[model_providers."paseo:my-codex"] name = "My Codex" base_url = "https://custom-relay.example.com/v1" wire_api = "responses" 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 61a86ccb8f..62e6bb0404 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 @@ -2623,9 +2623,9 @@ describe("Codex app-server provider", () => { OPENAI_BASE_URL: "https://custom-relay.example.com", }); expect(capturedThreadStartConfig(capturedRequests)).toEqual({ - model_provider: "codex-iisb", + model_provider: "paseo:codex-iisb", model_providers: { - "codex-iisb": { + "paseo:codex-iisb": { name: "Custom Codex", base_url: "https://custom-relay.example.com/v1", env_key: "OPENAI_API_KEY", @@ -2643,15 +2643,30 @@ describe("Codex app-server provider", () => { ); expect(capturedThreadStartConfig(capturedRequests)).toEqual({ - model_provider: "codex-custom", + model_provider: "paseo:codex-custom", model_providers: { - "codex-custom": expect.objectContaining({ + "paseo:codex-custom": expect.objectContaining({ base_url: "https://custom-relay.example.com/v1", }), }, }); }); + test("namespaces provider ids that collide with Codex built-ins", async () => { + // `ollama` is a reserved Codex provider id: without namespacing, Codex rejects + // the generated config outright and the agent never starts. + const capturedRequests = await runCustomCodexProviderTurn("ollama", "https://ollama.com"); + + expect(capturedThreadStartConfig(capturedRequests)).toEqual({ + model_provider: "paseo:ollama", + model_providers: { + "paseo:ollama": expect.objectContaining({ + base_url: "https://ollama.com/v1", + }), + }, + }); + }); + test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => { const threadRequests: string[] = []; const appServer = createFakeCodexAppServer({ @@ -7348,9 +7363,21 @@ describe("Codex importable sessions", () => { const threadsByModelProvider = [ { id: "stock-thread", cwd: "/workspace/project-a", modelProvider: "openai", updatedAt: 2 }, { id: "custom-thread", cwd: "/workspace/project-a", modelProvider: "my-codex", updatedAt: 1 }, + { + id: "new-custom-thread", + cwd: "/workspace/project-a", + modelProvider: "paseo:my-codex", + updatedAt: 3, + }, + { + id: "other-custom-thread", + cwd: "/workspace/project-a", + modelProvider: "paseo-my-codex", + updatedAt: 4, + }, ]; - test("a custom Codex provider lists the threads it created", async () => { + test("a custom Codex provider lists its old and namespaced threads without other providers", async () => { const appServer = createFakeCodexAppServer({ "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), }); @@ -7363,10 +7390,40 @@ describe("Codex importable sessions", () => { const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); - expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); + expect(sessions.map((session) => session.providerHandleId)).toEqual([ + "custom-thread", + "new-custom-thread", + ]); appServer.assertNoErrors(); }); + test.each(["openai", "ollama", "lmstudio", "amazon-bedrock", "amazon-bedrock-runtime", "gcp"])( + "a custom provider named %s excludes native built-in sessions", + async (providerId) => { + const appServer = createFakeCodexAppServer({ + "thread/list": providerFilteringThreadListHandler([ + { id: "native-thread", cwd: "/workspace/project-a", modelProvider: providerId }, + { + id: "custom-thread", + cwd: "/workspace/project-a", + modelProvider: `paseo:${providerId}`, + }, + ]), + }); + const provider = createProviderWithFakeAppServer(appServer, { + runtimeSettings: { + env: { OPENAI_BASE_URL: "https://llm.example.test/v1", OPENAI_API_KEY: "test-key" }, + }, + customProvider: { id: providerId, label: "Custom Codex", extends: "codex" }, + }); + + const sessions = await provider.listImportableSessions({ cwd: "/workspace/project-a" }); + + expect(sessions.map((session) => session.providerHandleId)).toEqual(["custom-thread"]); + appServer.assertNoErrors(); + }, + ); + test("stock Codex keeps listing only its own threads", async () => { const appServer = createFakeCodexAppServer({ "thread/list": providerFilteringThreadListHandler(threadsByModelProvider), 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 75370f0b5b..ea4e6378d9 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 @@ -151,6 +151,16 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea const TURN_START_TIMEOUT_MS = 90 * 1000; const INTERRUPT_TIMEOUT_MS = 2_000; const CODEX_PROVIDER = "codex" as const; +// These native provider identities belong to Codex, not to an older custom +// Paseo provider. Never include them in a custom provider's legacy import scan. +const CODEX_BUILTIN_MODEL_PROVIDER_IDS = new Set([ + "openai", + "ollama", + "lmstudio", + "amazon-bedrock", + "amazon-bedrock-runtime", + "gcp", +]); // Codex treats most app-server client names as the model-request originator. // This reserved Codex name is non-originating, so requests keep Codex's default // CLI identity instead of showing up as Paseo in provider usage logs. @@ -3368,10 +3378,14 @@ function buildCodexCustomProviderConfig( providerConfig.env_key = "OPENAI_API_KEY"; providerConfig.requires_openai_auth = false; } + // Namespace Paseo ids so they cannot override Codex's built-ins. A colon is + // forbidden in Paseo ids, keeping these keys distinct from every legacy id + // when both generations are listed together. + const providerKey = `paseo:${customProvider.id}`; return { - model_provider: customProvider.id, + model_provider: providerKey, model_providers: { - [customProvider.id]: providerConfig, + [providerKey]: providerConfig, }, }; } @@ -4059,6 +4073,11 @@ export class CodexAppServerAgentSession implements AgentSession { if (!this.client || !this.currentThreadId) return; // Paseo reads history only when it replays it; the resume response needs no turns. const params: Record = { threadId: this.currentThreadId, excludeTurns: true }; + if (this.deps.customCodexConfig) { + // Resume otherwise prefers the provider stored on the thread, including + // the unnamespaced key emitted by earlier Paseo versions. + params.modelProvider = this.deps.customCodexConfig.model_provider; + } const developerInstructions = composeSystemPromptParts( this.config.systemPrompt, this.config.daemonAppendSystemPrompt, @@ -7373,12 +7392,19 @@ export class CodexAppServerAgentClient implements AgentClient { const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit; // Codex records each thread under the model provider that ran it and, // unless told otherwise, lists only the provider its own config selects. - // A custom provider runs under its own id, so ask for exactly that one. + // Include legacy custom ids, but keep Codex built-in sessions separate. const customProviderConfig = this.customProviderConfig(); + let modelProviders: string[] | undefined; + if (customProviderConfig && this.deps.customProvider) { + modelProviders = [customProviderConfig.model_provider]; + if (!CODEX_BUILTIN_MODEL_PROVIDER_IDS.has(this.deps.customProvider.id)) { + modelProviders.push(this.deps.customProvider.id); + } + } const allThreads = await readCodexThreadWindow(client, this.logger, { limit: listLimit, cwd: options?.cwd, - ...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}), + ...(modelProviders ? { modelProviders } : {}), }); const threads = filterCodexThreadsByCwd(allThreads, options?.cwd); return threads.slice(0, limit).map((thread) => { diff --git a/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts index ebd8de5ab5..f546dc69e7 100644 --- a/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-custom-provider-import.local.e2e.test.ts @@ -7,7 +7,8 @@ import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; import { createTestLogger } from "../../../test-utils/test-logger.js"; -import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; +import { spawnProcess } from "../../../utils/spawn.js"; +import { CodexAppServerAgentClient, CodexAppServerAgentSession } from "./codex-app-server-agent.js"; // Runs the real `codex` binary against a local Responses endpoint standing in // for a custom provider's OPENAI_BASE_URL, so no login is needed. @@ -88,52 +89,106 @@ describe("Codex custom provider import list", () => { for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); }); - test("lists a custom provider's session under that provider and imports it there", async () => { - const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); - const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); - cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); - cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); - const { baseUrl, server, requestCount } = await startResponsesEndpoint(); - cleanups.push(() => closeServer(server)); + test.each([ + { providerId: "my-codex", seedLegacy: false, seedBuiltin: false }, + { providerId: "ollama", seedLegacy: false, seedBuiltin: false }, + { providerId: "my-codex", seedLegacy: true, seedBuiltin: false }, + { providerId: "ollama", seedLegacy: false, seedBuiltin: true }, + ])( + "lists and imports sessions for $providerId (legacy: $seedLegacy, native: $seedBuiltin)", + async ({ providerId, seedLegacy, seedBuiltin }) => { + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-home-")); + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-custom-import-cwd-")); + cleanups.push(() => rmSync(codexHome, { recursive: true, force: true })); + cleanups.push(() => rmSync(cwd, { recursive: true, force: true })); + const { baseUrl, server, requestCount } = await startResponsesEndpoint(); + cleanups.push(() => closeServer(server)); - const logger = createTestLogger(); - const customCodex = new CodexAppServerAgentClient( - logger, - { env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, - { customProvider: { id: "my-codex", label: "My Codex", extends: "codex" } }, - ); - const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); + const logger = createTestLogger(); + if (seedLegacy || seedBuiltin) { + // Seed an old custom session or a native built-in session with real Codex. + const environment = { + ...process.env, + CODEX_HOME: codexHome, + CODEX_OSS_BASE_URL: baseUrl, + OPENAI_API_KEY: "test-key", + }; + const legacy = new CodexAppServerAgentSession( + { provider: "codex", cwd, model: "mock-model" }, + null, + logger, + async () => + spawnProcess("codex", ["app-server"], { cwd, env: environment, stdio: "pipe" }), + { + environment, + customCodexConfig: { + model_provider: providerId, + model_providers: seedBuiltin + ? {} + : { + [providerId]: { + name: "My Codex", + base_url: baseUrl, + wire_api: "responses", + env_key: "OPENAI_API_KEY", + requires_openai_auth: false, + }, + }, + }, + }, + ); + try { + await legacy.run("hello from the legacy provider"); + } finally { + await legacy.close(); + } + } + const customCodex = new CodexAppServerAgentClient( + logger, + { env: { CODEX_HOME: codexHome, OPENAI_BASE_URL: baseUrl, OPENAI_API_KEY: "test-key" } }, + { customProvider: { id: providerId, label: "My Codex", extends: "codex" } }, + ); + const stockCodex = new CodexAppServerAgentClient(logger, { env: { CODEX_HOME: codexHome } }); - const session = await customCodex.createSession({ - provider: "codex", - cwd, - model: "mock-model", - }); - try { - await session.run("hello from the custom provider"); - } finally { - await session.close(); - } + const session = await customCodex.createSession({ + provider: "codex", + cwd, + model: "mock-model", + }); + try { + await session.run("hello from the custom provider"); + } finally { + await session.close(); + } - const customSessions = await customCodex.listImportableSessions({ cwd }); - const stockSessions = await stockCodex.listImportableSessions({ cwd }); + const customSessions = await customCodex.listImportableSessions({ cwd }); + const stockSessions = await stockCodex.listImportableSessions({ cwd }); - expect(customSessions.map((entry) => entry.firstPromptPreview)).toEqual([ - "hello from the custom provider", - ]); - expect(stockSessions).toEqual([]); + expect(customSessions.map((entry) => entry.firstPromptPreview).sort()).toEqual( + seedLegacy + ? ["hello from the custom provider", "hello from the legacy provider"] + : ["hello from the custom provider"], + ); + expect(stockSessions).toEqual([]); - const config = { provider: "codex", cwd, model: "mock-model" }; - const imported = await customCodex.importSession( - { providerHandleId: customSessions[0].providerHandleId, cwd }, - { config, storedConfig: config }, - ); - const requestsBeforeImportedTurn = requestCount(); - try { - await imported.session.run("hello again after import"); - } finally { - await imported.session.close(); - } - expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); - }, 120_000); + const config = { provider: "codex", cwd, model: "mock-model" }; + const sessionToImport = seedLegacy + ? customSessions.find( + (entry) => entry.firstPromptPreview === "hello from the legacy provider", + )! + : customSessions[0]; + const imported = await customCodex.importSession( + { providerHandleId: sessionToImport.providerHandleId, cwd }, + { config, storedConfig: config }, + ); + const requestsBeforeImportedTurn = requestCount(); + try { + await imported.session.run("hello again after import"); + } finally { + await imported.session.close(); + } + expect(requestCount()).toBeGreaterThan(requestsBeforeImportedTurn); + }, + 120_000, + ); });