From 4b6106a636cf631a9c08854216617fbb2789154b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:43:52 +0200 Subject: [PATCH] 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 --- docs/custom-providers.md | 2 +- .../providers/codex-app-server-agent.test.ts | 42 +++++- .../agent/providers/codex-app-server-agent.ts | 19 ++- ...x-custom-provider-import.local.e2e.test.ts | 138 ++++++++++++------ 4 files changed, 146 insertions(+), 55 deletions(-) diff --git a/docs/custom-providers.md b/docs/custom-providers.md index a6c6cefbdc..35667ac38b 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 sessions created with the original provider key remain available in Import session. ### Setup 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 c8269722a7..f21802708d 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 @@ -2319,9 +2319,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", @@ -2339,15 +2339,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({ @@ -6893,9 +6908,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), }); @@ -6908,7 +6935,10 @@ 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(); }); 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 36267592f8..d1c9bc6b0c 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 @@ -3384,10 +3384,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, }, }; } @@ -4061,6 +4065,11 @@ export class CodexAppServerAgentSession implements AgentSession { private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; const params: Record = { threadId: this.currentThreadId }; + 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, @@ -7344,12 +7353,14 @@ 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 the original id for sessions created before namespacing. const customProviderConfig = this.customProviderConfig(); const allThreads = await readCodexThreadWindow(client, this.logger, { limit: listLimit, cwd: options?.cwd, - ...(customProviderConfig ? { modelProviders: [customProviderConfig.model_provider] } : {}), + ...(customProviderConfig && this.deps.customProvider + ? { modelProviders: [customProviderConfig.model_provider, this.deps.customProvider.id] } + : {}), }); 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..39f11a8212 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 @@ -1,5 +1,6 @@ import { mkdtempSync, rmSync } from "node:fs"; import { once } from "node:events"; +import { spawn } from "node:child_process"; import { createServer, type Server } from "node:http"; import os from "node:os"; import path from "node:path"; @@ -7,7 +8,7 @@ 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 { 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,101 @@ 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 }, + { providerId: "ollama", seedLegacy: false }, + { providerId: "my-codex", seedLegacy: true }, + ])( + "lists and imports sessions for $providerId (legacy session: $seedLegacy)", + async ({ providerId, seedLegacy }) => { + 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) { + // Recreate the config emitted before namespacing with the real app-server. + const environment = { + ...process.env, + CODEX_HOME: codexHome, + OPENAI_API_KEY: "test-key", + }; + const legacy = new CodexAppServerAgentSession( + { provider: "codex", cwd, model: "mock-model" }, + null, + logger, + async () => spawn("codex", ["app-server"], { cwd, env: environment, stdio: "pipe" }), + { + environment, + customCodexConfig: { + model_provider: providerId, + model_providers: { + [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, + ); });