mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-21 00:32:45 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acba926048 | ||
|
|
ff21dc2315 | ||
|
|
4b6106a636 |
@@ -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) => {
|
||||
|
||||
+98
-44
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user