Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] acba926048 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.
2026-10-15 22:17:35 +02:00
paseo-bot[bot] ff21dc2315 fix(codex): keep native sessions out of custom provider imports 2026-10-15 21:43:17 +02:00
paseo-bot[bot]andEgg3901 4b6106a636 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 <rainfordmason@gmail.com>
2026-10-15 21:43:17 +02:00
4 changed files with 193 additions and 57 deletions
+3 -3
View File
@@ -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:<provider-id>`, 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"
@@ -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,10 +6935,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"])(
"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),
@@ -145,6 +145,15 @@ 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",
]);
// 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.
@@ -3384,10 +3393,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 +4074,11 @@ export class CodexAppServerAgentSession implements AgentSession {
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
const params: Record<string, unknown> = { 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 +7362,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) => {
@@ -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,105 @@ 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 () => spawn("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,
);
});