From fce3d14a83c7f1b5bf2fcaf9ff7195a7ac615635 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Jul 2026 10:38:17 +0200 Subject: [PATCH] feat(paseo-agent): add model provider catalog Keep persisted provider config structurally open and resolve catalog semantics at runtime. --- packages/cli/src/commands/login/index.ts | 26 +- .../agent/providers/paseo-agent/agent.test.ts | 9 +- .../agent/providers/paseo-agent/agent.ts | 4 +- .../agent/providers/paseo-agent/catalog.ts | 100 +++++++ .../paseo-agent/config-service.test.ts | 67 ++++- .../providers/paseo-agent/config-service.ts | 67 +++-- .../providers/paseo-agent/config.test.ts | 263 ++++++++--------- .../agent/providers/paseo-agent/config.ts | 269 +++++------------- .../paseo-agent/oauth-credentials.ts | 9 +- .../paseo-agent/oauth-store.posix.test.ts | 19 +- .../providers/paseo-agent/oauth-store.test.ts | 109 ++++--- .../providers/paseo-agent/oauth-store.ts | 146 +++++----- .../providers/paseo-agent/pi-services.test.ts | 90 ++++-- .../providers/paseo-agent/pi-services.ts | 29 +- packages/server/src/server/exports.ts | 10 +- packages/server/src/server/session.ts | 4 +- 16 files changed, 649 insertions(+), 572 deletions(-) create mode 100644 packages/server/src/server/agent/providers/paseo-agent/catalog.ts diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts index 5b26e3023..58fbba5cb 100644 --- a/packages/cli/src/commands/login/index.ts +++ b/packages/cli/src/commands/login/index.ts @@ -1,12 +1,8 @@ import { createInterface } from "node:readline/promises"; import { Command } from "commander"; -import { - loginCodexBrowser, - loginAndStoreCodex, - type CodexDeviceCodeInfo, - type StoredCodexOAuthCredential, -} from "@getpaseo/server"; +import { loginOAuthBrowser, loginAndStoreOAuth, type OAuthDeviceCodeInfo } from "@getpaseo/server"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { PaseoAgentOAuthCredential } from "@getpaseo/protocol/messages"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { connectToDaemon } from "../../utils/client.js"; @@ -25,6 +21,7 @@ import { // remains a local-only fallback until a daemon-run device-code RPC exists. const PROVIDER_INSTANCE = "chatgpt"; +const OAUTH_FLOW = "openai-codex"; interface LoginChatgptOptions extends CommandOptions { deviceCode?: boolean; @@ -39,8 +36,8 @@ interface LoginResult { } interface LoginCommandDependencies { - loginDeviceCode: typeof loginAndStoreCodex; - loginBrowserCredential: typeof loginCodexBrowser; + loginDeviceCode: typeof loginAndStoreOAuth; + loginBrowserCredential: typeof loginOAuthBrowser; connectDaemon: (options: { host?: string; }) => Promise< @@ -53,8 +50,8 @@ interface LoginCommandDependencies { } const defaultDependencies: LoginCommandDependencies = { - loginDeviceCode: loginAndStoreCodex, - loginBrowserCredential: loginCodexBrowser, + loginDeviceCode: loginAndStoreOAuth, + loginBrowserCredential: loginOAuthBrowser, connectDaemon: connectToDaemon, openBrowser: openBrowserUrl, promptForCode, @@ -115,11 +112,12 @@ async function promptForCode(message: string): Promise { } } -function printDeviceCode(write: (message: string) => void, info: CodexDeviceCodeInfo): void { +function printDeviceCode(write: (message: string) => void, info: OAuthDeviceCodeInfo): void { + const expiresInSeconds = info.expiresInSeconds ?? 900; write("To authorize Paseo:"); write(` 1. Open: ${info.verificationUri}`); write(` 2. Enter code: ${info.userCode}`); - write(` (expires in ~${Math.round(info.expiresInSeconds / 60)} min — waiting...)\n`); + write(` (expires in ~${Math.round(expiresInSeconds / 60)} min — waiting...)\n`); } async function runChatgptLogin( @@ -140,6 +138,7 @@ async function runChatgptLogin( if (options.deviceCode) { write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n"); const { path } = await dependencies.loginDeviceCode({ + flow: OAUTH_FLOW, providerInstance: PROVIDER_INSTANCE, env, onDeviceCode: (info) => printDeviceCode(write, info), @@ -161,7 +160,8 @@ async function runChatgptLogin( try { requirePaseoAgentConfigFeature(client); write("Paseo login — ChatGPT/Codex subscription (browser flow)\n"); - const credential: StoredCodexOAuthCredential = await dependencies.loginBrowserCredential({ + const credential: PaseoAgentOAuthCredential = await dependencies.loginBrowserCredential({ + flow: OAUTH_FLOW, onAuthUrl: (url) => { const opened = dependencies.openBrowser(url); write( diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts index 5e966258c..4e1a2c06d 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.test.ts @@ -9,7 +9,7 @@ import { createTestLogger } from "../../../../test-utils/test-logger.js"; import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js"; import { PaseoAgentClient, PaseoAgentSession } from "./agent.js"; import { PaseoAgentConfigSchema, type PaseoAgentConfig } from "./config.js"; -import { storeCodexOAuthCredential } from "./oauth-store.js"; +import { storeOAuthCredential } from "./oauth-store.js"; import type { PaseoAgentSessionHandle } from "./pi-services.js"; function makeConfig(): PaseoAgentConfig { @@ -175,13 +175,13 @@ describe("PaseoAgentClient", () => { expect(await empty.isAvailable()).toBe(false); }); - it("checks ChatGPT OAuth credentials in the configured Paseo home", async () => { + it("checks OAuth credentials in the configured Paseo home", async () => { const paseoHome = mkdtempSync(join(tmpdir(), "paseo-agent-client-")); const wrongHome = mkdtempSync(join(tmpdir(), "paseo-agent-wrong-home-")); tempDirs.push(paseoHome, wrongHome); const previousPaseoHome = process.env.PASEO_HOME; process.env.PASEO_HOME = wrongHome; - storeCodexOAuthCredential({ + storeOAuthCredential({ providerInstance: "chatgpt", credential: { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 0 }, env: { PASEO_HOME: paseoHome }, @@ -189,8 +189,7 @@ describe("PaseoAgentClient", () => { const config = PaseoAgentConfigSchema.parse({ providers: { chatgpt: { - type: "openai-codex", - options: { models: [{ id: "gpt-5.3-codex" }] }, + type: "chatgpt", }, }, }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/agent.ts b/packages/server/src/server/agent/providers/paseo-agent/agent.ts index f97e6b519..5b0299f9d 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/agent.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/agent.ts @@ -560,8 +560,8 @@ export class PaseoAgentClient implements AgentClient { "Creating Paseo Agent session", ); - // OAuth providers (ChatGPT/Codex) use a Paseo-owned, file-backed AuthStorage so Pi - // reads the stored credential and persists refreshed tokens (rotation) back to it. + // Catalog OAuth providers use a Paseo-owned, file-backed AuthStorage so Pi reads + // the stored credential and persists refreshed tokens (rotation) back to it. const usesOAuth = modelProviders.some((provider) => provider.oauth); const authStorage = usesOAuth ? createPaseoAgentAuthStorage(envForPaseoHome(this.paseoHome)) diff --git a/packages/server/src/server/agent/providers/paseo-agent/catalog.ts b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts new file mode 100644 index 000000000..8b2cdc893 --- /dev/null +++ b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts @@ -0,0 +1,100 @@ +export interface PaseoAgentCatalogModel { + id: string; + label?: string; + api?: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +} + +export interface PaseoAgentApiKeyAuth { + kind: "api_key"; + envVar: string; + keyUrl?: string; + placeholder?: string; + hint?: string; +} + +export interface PaseoAgentOAuthAuth { + kind: "oauth"; + flow: string; +} + +export type PaseoAgentCatalogAuth = PaseoAgentApiKeyAuth | PaseoAgentOAuthAuth; + +export interface PaseoAgentCatalogEntry { + id: string; + label: string; + iconName?: string; + docsUrl?: string; + api: string; + baseUrl: string; + headers?: Record; + compat?: Record; + auth: PaseoAgentCatalogAuth; + models: PaseoAgentCatalogModel[]; +} + +export const PASEO_AGENT_PROVIDER_CATALOG = [ + { + id: "openrouter", + label: "OpenRouter", + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + auth: { kind: "api_key", envVar: "OPENROUTER_API_KEY" }, + models: [], + }, + { + id: "chatgpt", + label: "ChatGPT", + iconName: "openai", + api: "openai-codex-responses", + baseUrl: "https://chatgpt.com/backend-api", + auth: { kind: "oauth", flow: "openai-codex" }, + models: [{ id: "gpt-5.3-codex", reasoning: true }], + }, + { + id: "kimi", + label: "Kimi Coding Plan", + api: "anthropic-messages", + baseUrl: "https://api.kimi.com/coding", + headers: { "User-Agent": "KimiCLI/1.5" }, + auth: { kind: "api_key", envVar: "KIMI_API_KEY" }, + models: [], + }, + { + id: "opencode-go", + label: "OpenCode Go", + api: "openai-completions", + baseUrl: "https://opencode.ai/zen/go/v1", + auth: { kind: "api_key", envVar: "OPENCODE_API_KEY" }, + models: [], + }, +] as const satisfies readonly PaseoAgentCatalogEntry[]; + +const PASEO_AGENT_PROVIDER_ALIASES: Record = { + "openai-codex": "chatgpt", +}; + +export function resolvePaseoAgentCatalogEntry( + providerType: string, +): PaseoAgentCatalogEntry | undefined { + const canonicalId = PASEO_AGENT_PROVIDER_ALIASES[providerType] ?? providerType; + return PASEO_AGENT_PROVIDER_CATALOG.find((entry) => entry.id === canonicalId); +} + +export function knownPaseoAgentCatalogIds(): string[] { + return PASEO_AGENT_PROVIDER_CATALOG.map((entry) => entry.id); +} + +export function unknownPaseoAgentProviderTypeMessage(providerType: string): string { + return `Unknown model provider type "${providerType}". Known provider ids: ${knownPaseoAgentCatalogIds().join(", ")}. Update the host if this provider is newer than it.`; +} + +export function requirePaseoAgentCatalogEntry(providerType: string): PaseoAgentCatalogEntry { + const entry = resolvePaseoAgentCatalogEntry(providerType); + if (!entry) { + throw new Error(unknownPaseoAgentProviderTypeMessage(providerType)); + } + return entry; +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts index 999c8b7b5..418f14836 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { createTestLogger } from "../../../../test-utils/test-logger.js"; import { loadPersistedConfig, savePersistedConfig } from "../../../persisted-config.js"; import { PaseoAgentConfigService } from "./config-service.js"; +import { PaseoAgentConfigSchema } from "./config.js"; import { paseoAgentAuthStoragePath } from "./oauth-store.js"; describe("PaseoAgentConfigService", () => { @@ -67,10 +68,52 @@ describe("PaseoAgentConfigService", () => { providerType: "kimi-coding", options: { apiKey: "sk-test", models: [{ id: "kimi-k3" }] }, }), - ).toThrow(/Unknown model provider type "kimi-coding". Known types: openrouter/); + ).toThrow( + /Unknown model provider type "kimi-coding". Known provider ids: openrouter, chatgpt, kimi, opencode-go/, + ); expect(loadPersistedConfig(home).agents?.paseo?.providers).toBeUndefined(); }); + test("schema accepts an unknown type structurally and the service rejects it", () => { + const parsed = PaseoAgentConfigSchema.parse({ + providers: { + future: { type: "future-provider", options: { models: [{ id: "m" }] } }, + }, + }); + expect(parsed.providers?.future?.type).toBe("future-provider"); + savePersistedConfig( + home, + { + agents: { paseo: parsed }, + }, + createTestLogger(), + ); + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + + expect(() => service.getProviders()).toThrow( + /Unknown model provider type "future-provider". Known provider ids: openrouter, chatgpt, kimi, opencode-go/, + ); + }); + + test("maps the legacy provider type alias to the catalog id on write", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + + const provider = service.setProvider({ + name: "chatgpt", + providerType: "openai-codex", + options: {}, + }); + + expect(provider.providerType).toBe("chatgpt"); + expect(loadPersistedConfig(home).agents?.paseo?.providers?.chatgpt?.type).toBe("chatgpt"); + }); + test("preserves shared config fields when writing agents.paseo", () => { const logger = createTestLogger(); savePersistedConfig( @@ -116,13 +159,18 @@ describe("PaseoAgentConfigService", () => { ); }); - test("stores ChatGPT OAuth credentials in the Paseo-owned auth store with future fields intact", () => { + test("stores OAuth credentials in the Paseo-owned auth store with future fields intact", () => { const service = new PaseoAgentConfigService({ paseoHome: home, logger: createTestLogger(), }); + service.setProvider({ + name: "chatgpt", + providerType: "chatgpt", + options: {}, + }); - service.storeChatGptCredential("chatgpt", { + service.storeOAuthCredential("chatgpt", { type: "oauth", access: "access-token", refresh: "refresh-token", @@ -142,19 +190,17 @@ describe("PaseoAgentConfigService", () => { expect(authPath).toBe(join(home, "paseo-agent", "auth.json")); }); - test("reports ChatGPT auth as stored without returning tokens", () => { + test("reports OAuth auth as stored without returning tokens", () => { const service = new PaseoAgentConfigService({ paseoHome: home, logger: createTestLogger(), }); service.setProvider({ name: "chatgpt", - providerType: "openai-codex", - options: { - models: [{ id: "gpt-5.3-codex", reasoning: true }], - }, + providerType: "chatgpt", + options: {}, }); - service.storeChatGptCredential("chatgpt", { + service.storeOAuthCredential("chatgpt", { type: "oauth", access: "access-token", refresh: "refresh-token", @@ -165,7 +211,8 @@ describe("PaseoAgentConfigService", () => { expect(providers.providers).toEqual([ expect.objectContaining({ name: "chatgpt", - providerType: "openai-codex", + providerType: "chatgpt", + models: [{ id: "gpt-5.3-codex", reasoning: true }], auth: { kind: "oauth", configured: true, source: "stored" }, available: true, }), diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index d5cbd0f57..faa50c551 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -13,11 +13,11 @@ import { import { PaseoAgentConfigSchema, type PaseoAgentConfig, - isPaseoAgentProviderType, - knownPaseoAgentProviderTypes, - resolvePaseoAgentProviderTypeDefaults, + resolvePaseoAgentProviderModels, + resolvePaseoAgentProviderSettings, } from "./config.js"; -import { hasStoredOAuthCredential, storeCodexOAuthCredential } from "./oauth-store.js"; +import { requirePaseoAgentCatalogEntry } from "./catalog.js"; +import { hasStoredOAuthCredential, storeOAuthCredential } from "./oauth-store.js"; import { isRefreshTokenExpressionConfigured } from "./oauth-credentials.js"; import { findEnvReferences } from "./env-references.js"; @@ -37,7 +37,7 @@ interface SetProviderInput { api?: string; headers?: Record; authHeader?: boolean; - models: Array<{ + models?: Array<{ id: string; label?: string; api?: string; @@ -84,7 +84,14 @@ function authStateForApiKey( } function readPaseoAgentConfig(persisted: PersistedConfig): PaseoAgentConfig { - return PaseoAgentConfigSchema.parse(persisted.agents?.paseo ?? {}); + return validatePaseoAgentConfig(PaseoAgentConfigSchema.parse(persisted.agents?.paseo ?? {})); +} + +function validatePaseoAgentConfig(config: PaseoAgentConfig): PaseoAgentConfig { + for (const entry of Object.values(config.providers ?? {})) { + requirePaseoAgentCatalogEntry(entry.type); + } + return config; } function redactedProviders( @@ -92,9 +99,11 @@ function redactedProviders( env: NodeJS.ProcessEnv, ): RedactedPaseoAgentProviderConfig[] { return Object.entries(config.providers ?? {}).map(([name, entry]) => { - const defaults = resolvePaseoAgentProviderTypeDefaults(entry.type); + const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); + const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry); + const models = resolvePaseoAgentProviderModels(entry, catalogEntry); let auth: PaseoAgentProviderAuthState; - if (entry.type === "openai-codex") { + if (catalogEntry.auth.kind === "oauth") { const hasRefreshToken = entry.options.refreshToken && isRefreshTokenExpressionConfigured(entry.options.refreshToken, env); @@ -107,24 +116,18 @@ function redactedProviders( : { kind: "oauth", configured: false }; } } else { - auth = authStateForApiKey(entry.options.apiKey, defaults.envVar, env); + auth = authStateForApiKey(entry.options.apiKey, catalogEntry.auth.envVar, env); } const provider: RedactedPaseoAgentProviderConfig = { name, - providerType: entry.type, - models: entry.options.models.map((model) => ({ ...model })), + providerType: catalogEntry.id, + models: models.map((model) => ({ ...model })), auth, - available: auth.configured && entry.options.models.length > 0, + available: auth.configured && models.length > 0, error: null, }; - const baseUrl = entry.options.baseUrl ?? defaults.baseUrl; - if (baseUrl) { - provider.baseUrl = baseUrl; - } - const api = entry.options.api ?? defaults.api; - if (api) { - provider.api = api; - } + provider.baseUrl = settings.baseUrl; + provider.api = settings.api; return provider; }); } @@ -164,18 +167,14 @@ export class PaseoAgentConfigService { } setProvider(input: SetProviderInput): RedactedPaseoAgentProviderConfig { - if (!isPaseoAgentProviderType(input.providerType)) { - throw new Error( - `Unknown model provider type "${input.providerType}". Known types: ${knownPaseoAgentProviderTypes().join(", ")}. Update the host if this type is newer than it.`, - ); - } + const catalogEntry = requirePaseoAgentCatalogEntry(input.providerType); const next = this.updateConfig((current) => PaseoAgentConfigSchema.parse({ ...current, providers: { ...current.providers, [input.name]: { - type: input.providerType, + type: catalogEntry.id, options: input.options, }, }, @@ -199,13 +198,21 @@ export class PaseoAgentConfigService { return removed; } - storeChatGptCredential(providerName: string, credential: PaseoAgentOAuthCredential): void { - storeCodexOAuthCredential({ + storeOAuthCredential(providerName: string, credential: PaseoAgentOAuthCredential): void { + const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); + const entry = config.providers?.[providerName]; + if (!entry) { + throw new Error(`Paseo Agent provider '${providerName}' is not configured.`); + } + const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); + if (catalogEntry.auth.kind !== "oauth") { + throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`); + } + storeOAuthCredential({ providerInstance: providerName, credential, env: this.env, }); - const config = readPaseoAgentConfig(loadPersistedConfig(this.paseoHome, this.logger)); this.onConfigChanged?.(config); } @@ -222,7 +229,7 @@ export class PaseoAgentConfigService { private updateConfig(update: (current: PaseoAgentConfig) => PaseoAgentConfig): PaseoAgentConfig { const persisted = loadPersistedConfig(this.paseoHome, this.logger); - const next = update(readPaseoAgentConfig(persisted)); + const next = validatePaseoAgentConfig(update(readPaseoAgentConfig(persisted))); savePersistedConfig(this.paseoHome, mergePaseoAgentConfig(persisted, next), this.logger); this.onConfigChanged?.(next); return next; diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts index a2715ad4c..103c7db97 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -34,15 +34,16 @@ describe("PaseoAgentConfigSchema", () => { expect(() => PaseoAgentConfigSchema.parse({ providers: {}, unexpected: true })).toThrow(); }); - it("rejects an unknown model provider type", () => { - expect(() => - PaseoAgentConfigSchema.parse({ - providers: { p: { type: "mystery", options: { models: [{ id: "m" }] } } }, - }), - ).toThrow(); + it("accepts unknown model provider types structurally", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + future: { type: "future-provider", options: { models: [{ id: "m" }] } }, + }, + }); + expect(config.providers?.future?.type).toBe("future-provider"); }); - it("requires at least one model per model provider", () => { + it("rejects an empty instance model override", () => { expect(() => PaseoAgentConfigSchema.parse({ providers: { p: { type: "openrouter", options: { models: [] } } }, @@ -50,36 +51,31 @@ describe("PaseoAgentConfigSchema", () => { ).toThrow(); }); - it("requires baseUrl for openai-compatible and api for custom", () => { - expect(() => - PaseoAgentConfigSchema.parse({ - providers: { p: { type: "openai-compatible", options: { models: [{ id: "m" }] } } }, - }), - ).toThrow(/baseUrl/); - expect(() => - PaseoAgentConfigSchema.parse({ - providers: { - p: { type: "custom", options: { baseUrl: "https://x.test", models: [{ id: "m" }] } }, - }, - }), - ).toThrow(/api/); - }); - - it("accepts an empty config", () => { - expect(PaseoAgentConfigSchema.parse({})).toEqual({}); + it("accepts a provider entry without options when the catalog supplies defaults", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { chatgpt: { type: "chatgpt" } }, + }); + expect(config.providers?.chatgpt?.options).toEqual({}); }); it("accepts multiple entries of the same type with distinct names", () => { const config = PaseoAgentConfigSchema.parse({ providers: { - "openai-a": { type: "openai", options: { apiKey: "sk-a", models: [{ id: "gpt-a" }] } }, - "openai-b": { - type: "openai", - options: { baseUrl: "https://proxy.test/v1", apiKey: "sk-b", models: [{ id: "gpt-b" }] }, + "openrouter-a": { + type: "openrouter", + options: { apiKey: "sk-a", models: [{ id: "model-a" }] }, + }, + "openrouter-b": { + type: "openrouter", + options: { + baseUrl: "https://proxy.test/v1", + apiKey: "sk-b", + models: [{ id: "model-b" }], + }, }, }, }); - expect(Object.keys(config.providers ?? {})).toEqual(["openai-a", "openai-b"]); + expect(Object.keys(config.providers ?? {})).toEqual(["openrouter-a", "openrouter-b"]); }); }); @@ -107,6 +103,13 @@ describe("listPaseoAgentModels", () => { expect(models.every((m) => m.provider === "paseo")).toBe(true); }); + it("uses catalog model defaults when an instance does not override them", () => { + const models = listPaseoAgentModels( + PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }), + ); + expect(models.map((m) => m.id)).toEqual(["chatgpt/gpt-5.3-codex"]); + }); + it("marks the configured default model", () => { const models = listPaseoAgentModels(configWith({ defaultModel: "openrouter-main/openai/gpt" })); const defaults = models.filter((m) => m.isDefault).map((m) => m.id); @@ -114,8 +117,8 @@ describe("listPaseoAgentModels", () => { }); }); -describe("paseoAgentModelProviders (per-type defaults)", () => { - it("applies openrouter defaults (base url, api, model fields)", async () => { +describe("paseoAgentModelProviders", () => { + it("applies OpenRouter catalog defaults", async () => { const [provider] = await paseoAgentModelProviders(configWith()); expect(provider.name).toBe("openrouter-main"); expect(provider.config.baseUrl).toBe("https://openrouter.ai/api/v1"); @@ -130,62 +133,94 @@ describe("paseoAgentModelProviders (per-type defaults)", () => { }); }); - it("falls back to the type's env var when no apiKey is given", async () => { + it("falls back to the catalog env var when no apiKey is given", async () => { const config = PaseoAgentConfigSchema.parse({ providers: { - anthropic: { type: "anthropic", options: { models: [{ id: "claude-x" }] } }, + openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } }, }, }); const [provider] = await paseoAgentModelProviders(config); - expect(provider.config.baseUrl).toBe("https://api.anthropic.com"); - expect(provider.config.apiKey).toBe("$ANTHROPIC_API_KEY"); + expect(provider.config.apiKey).toBe("$OPENROUTER_API_KEY"); + }); + + it("applies the Kimi catalog API and default header", async () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + kimi: { type: "kimi", options: { models: [{ id: "kimi-k2" }] } }, + }, + }); + const [provider] = await paseoAgentModelProviders(config); + expect(provider.config.baseUrl).toBe("https://api.kimi.com/coding"); + expect(provider.config.apiKey).toBe("$KIMI_API_KEY"); + expect(provider.config.api).toBe("anthropic-messages"); + expect(provider.config.headers).toEqual({ "User-Agent": "KimiCLI/1.5" }); expect(provider.config.models?.[0]?.api).toBe("anthropic-messages"); }); - it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", async () => { + it("applies the OpenCode Go catalog base URL", async () => { const config = PaseoAgentConfigSchema.parse({ providers: { - zen: { - type: "openai-compatible", - options: { - baseUrl: "https://opencode.ai/zen/v1", - apiKey: "$OPENCODE_API_KEY", - models: [{ id: "big-pickle" }, { id: "claude-sonnet", api: "anthropic-messages" }], - }, - }, + go: { type: "opencode-go", options: { models: [{ id: "glm-5" }] } }, }, }); const [provider] = await paseoAgentModelProviders(config); - expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/v1"); - expect(provider.config.models?.[0]?.api).toBe("openai-completions"); - expect(provider.config.models?.[1]?.api).toBe("anthropic-messages"); + expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/go/v1"); + expect(provider.config.apiKey).toBe("$OPENCODE_API_KEY"); + expect(provider.config.api).toBe("openai-completions"); }); - it("passes through the custom escape hatch (explicit api + authHeader)", async () => { - const config = PaseoAgentConfigSchema.parse({ - providers: { - vertex: { - type: "custom", - options: { - baseUrl: "https://my-gateway.test/v1", - api: "google-generative-ai", - apiKey: "sk-custom", - authHeader: true, - headers: { "x-extra": "1" }, - models: [{ id: "gemini" }], + it("maps OAuth catalog entries to flow-based providers without an api key", async () => { + const [provider] = await paseoAgentModelProviders( + PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }), + {}, + ); + expect(provider.name).toBe("chatgpt"); + expect(provider.oauth).toEqual({ flow: "openai-codex" }); + expect(provider.config.apiKey).toBeUndefined(); + expect(provider.config.api).toBe("openai-codex-responses"); + expect(provider.config.baseUrl).toBe("https://chatgpt.com/backend-api"); + expect(provider.config.models?.[0]?.id).toBe("gpt-5.3-codex"); + }); + + it("lets instance models override catalog default models", async () => { + const [provider] = await paseoAgentModelProviders( + PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { + type: "chatgpt", + options: { models: [{ id: "gpt-other", reasoning: false }] }, }, }, + }), + ); + expect(provider.config.models?.map((model) => model.id)).toEqual(["gpt-other"]); + }); + + it("maps the legacy type alias to the catalog entry", async () => { + const [provider] = await paseoAgentModelProviders( + PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { type: "openai-codex" }, + }, + }), + ); + expect(provider.oauth).toEqual({ flow: "openai-codex" }); + expect(provider.config.models?.[0]?.id).toBe("gpt-5.3-codex"); + }); + + it("rejects unknown provider types at runtime with known ids", async () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { + mystery: { type: "mystery", options: { models: [{ id: "m" }] } }, }, }); - const [provider] = await paseoAgentModelProviders(config); - expect(provider.config.api).toBe("google-generative-ai"); - expect(provider.config.authHeader).toBe(true); - expect(provider.config.headers).toEqual({ "x-extra": "1" }); - expect(provider.config.models?.[0]?.api).toBe("google-generative-ai"); + await expect(paseoAgentModelProviders(config)).rejects.toThrow( + /Unknown model provider type "mystery". Known provider ids: openrouter, chatgpt, kimi, opencode-go/, + ); }); }); -describe("paseoAgentHasUsableModel (env-aware auth)", () => { +describe("paseoAgentHasUsableModel", () => { it("is true for a literal api key", () => { expect(paseoAgentHasUsableModel(configWith(), {})).toBe(true); }); @@ -194,13 +229,10 @@ describe("paseoAgentHasUsableModel (env-aware auth)", () => { expect(paseoAgentHasUsableModel(PaseoAgentConfigSchema.parse({}), {})).toBe(false); }); - it("is false for an openai-compatible provider without any key", () => { + it("is false for an API-key provider without a configured key", () => { const config = PaseoAgentConfigSchema.parse({ providers: { - local: { - type: "openai-compatible", - options: { baseUrl: "https://local.test/v1", models: [{ id: "m" }] }, - }, + openrouter: { type: "openrouter", options: { models: [{ id: "m" }] } }, }, }); expect(paseoAgentHasUsableModel(config, {})).toBe(false); @@ -213,6 +245,21 @@ describe("paseoAgentHasUsableModel (env-aware auth)", () => { expect(paseoAgentHasUsableModel(config, {})).toBe(false); expect(paseoAgentHasUsableModel(config, { OPENROUTER_API_KEY: "sk-env" })).toBe(true); }); + + it("uses the OAuth store predicate, or an advanced refresh token", () => { + const config = PaseoAgentConfigSchema.parse({ + providers: { chatgpt: { type: "chatgpt" } }, + }); + expect(paseoAgentHasUsableModel(config, {})).toBe(false); + expect(paseoAgentHasUsableModel(config, {}, () => true)).toBe(true); + + const refreshConfig = PaseoAgentConfigSchema.parse({ + providers: { + chatgpt: { type: "chatgpt", options: { refreshToken: "$OAUTH_REFRESH_TOKEN" } }, + }, + }); + expect(paseoAgentHasUsableModel(refreshConfig, { OAUTH_REFRESH_TOKEN: "rt-env" })).toBe(true); + }); }); describe("resolvePaseoAgentModel", () => { @@ -242,6 +289,15 @@ describe("resolvePaseoAgentModel", () => { expect(resolvePaseoAgentModel(PaseoAgentConfigSchema.parse({}), null)).toBeUndefined(); }); + it("uses catalog default models during implicit selection", () => { + expect( + resolvePaseoAgentModel( + PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }), + null, + ), + ).toEqual({ provider: "chatgpt", id: "gpt-5.3-codex" }); + }); + it("ignores an implicit default whose provider is not registered", () => { const config = configWith({ defaultModel: "ghost/model" }); expect(resolvePaseoAgentModel(config, null)).toEqual({ @@ -257,70 +313,3 @@ describe("resolvePaseoAgentModel", () => { }); }); }); - -describe("openai-codex (ChatGPT subscription) provider", () => { - function codexConfig(options: Record): PaseoAgentConfig { - return PaseoAgentConfigSchema.parse({ - providers: { - chatgpt: { - type: "openai-codex", - options: { models: [{ id: "gpt-5.3-codex" }], ...options }, - }, - }, - }); - } - - it("accepts a codex provider with no credential field (login provides it)", () => { - const config = codexConfig({}); - expect(config.providers?.chatgpt?.type).toBe("openai-codex"); - }); - - it("rejects an unknown option like a foreign credentials file", () => { - expect(() => - PaseoAgentConfigSchema.parse({ - providers: { - chatgpt: { - type: "openai-codex", - options: { credentialsFile: "/Users/me/.codex/auth.json", models: [{ id: "x" }] }, - }, - }, - }), - ).toThrow(); - }); - - it("maps to a codex model provider with an oauth marker and no api key", async () => { - const [provider] = await paseoAgentModelProviders(codexConfig({}), {}); - expect(provider.name).toBe("chatgpt"); - expect(provider.oauth).toEqual({ kind: "openai-codex" }); - expect(provider.config.apiKey).toBeUndefined(); - expect(provider.config.api).toBe("openai-codex-responses"); - expect(provider.config.baseUrl).toBe("https://chatgpt.com/backend-api"); - expect(provider.config.models?.[0]?.api).toBe("openai-codex-responses"); - }); - - it("carries an advanced self-supplied refresh token resolved from an env var", async () => { - const config = codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }); - const [provider] = await paseoAgentModelProviders(config, { - CODEX_REFRESH_TOKEN: "rt-env", - }); - expect(provider.oauth).toEqual({ kind: "openai-codex", refreshToken: "rt-env" }); - }); - - it("availability uses the OAuth store predicate, or an advanced refresh token", () => { - // No stored credential and no advanced token → not available. - expect(paseoAgentHasUsableModel(codexConfig({}), {})).toBe(false); - // Stored credential (predicate true) → available. - expect(paseoAgentHasUsableModel(codexConfig({}), {}, () => true)).toBe(true); - // Advanced env-backed token → available without the store. - expect( - paseoAgentHasUsableModel(codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" }), { - CODEX_REFRESH_TOKEN: "rt-env", - }), - ).toBe(true); - }); - - it("lists codex models regardless of auth state", () => { - const models = listPaseoAgentModels(codexConfig({})); - expect(models.map((m) => m.id)).toEqual(["chatgpt/gpt-5.3-codex"]); - }); -}); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index b762e29c5..fcb418550 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -6,121 +6,19 @@ import { resolveRefreshTokenExpression, } from "./oauth-credentials.js"; import type { PaseoAgentModelProvider, PaseoAgentModelReference } from "./pi-services.js"; +import { + requirePaseoAgentCatalogEntry, + type PaseoAgentCatalogEntry, + type PaseoAgentCatalogModel, +} from "./catalog.js"; import { findEnvReferences } from "./env-references.js"; export const PASEO_AGENT_PROVIDER = "paseo"; -// Dedicated Paseo-owned config for the Paseo Agent provider. This is the single -// schema for `agents.paseo`; model-provider fields are intentionally NOT -// merged into the shared strict ProviderOverrideSchema, and only this provider -// (via the helpers below) consumes it. This module imports no Pi runtime code so -// it stays cheap to load from persisted-config parsing. -// -// Model providers are typed by `type`. Each known type carries sensible -// defaults (base URL, Pi wire `api`, and the env var its API key is read from), -// so a user only needs to give an `apiKey` (or the env var) and one or more model -// ids. `openai-compatible` covers any OpenAI Chat Completions endpoint (incl. -// OpenCode Zen/Go behind a custom base URL); `custom` is a thin escape hatch for -// directly choosing Pi's wire `api`. -// -// AUTH: API-key and env-var auth work for every type. ChatGPT/OpenAI subscription -// OAuth is supported via the `openai-codex` type. The product path is `paseo login -// chatgpt`, which runs Pi's browser PKCE/callback login by default and stores the -// credential in a Paseo-controlled file (see oauth-store.ts); the session loads it and -// Pi refreshes/persists rotation there. `options.refreshToken` is an advanced, manual -// escape hatch for users supplying their OWN token (literal/`$ENV`/`!cmd`) — it is not -// the normal path. Paseo never reads another tool's auth files. Other OAuth providers -// (e.g. Anthropic Pro/Max) remain unwired. - -const PROVIDER_TYPES = [ - "openrouter", - "openai", - "anthropic", - "opencode", - "openai-compatible", - "openai-codex", - "custom", -] as const; - -export type PaseoAgentProviderType = (typeof PROVIDER_TYPES)[number]; - -// The wire schema is an open string (protocol back-compat); the daemon owns -// the closed set. Use this to gate incoming provider types with a clear error. -export function isPaseoAgentProviderType(value: string): value is PaseoAgentProviderType { - return (PROVIDER_TYPES as readonly string[]).includes(value); -} - -export function knownPaseoAgentProviderTypes(): readonly string[] { - return PROVIDER_TYPES; -} - -export interface PaseoAgentProviderTypeDefault { - /** Pi wire protocol. `undefined` for `custom`, where the user must pick one. */ - api?: string; - /** Default base URL. `undefined` means the user must supply `options.baseUrl`. */ - baseUrl?: string; - /** Env var the API key is read from when `options.apiKey` is omitted. */ - envVar?: string; -} - -// Defaults mirror Pi's built-in provider definitions (packages/ai models). Pi adds -// its own attribution headers for openrouter/opencode based on the base URL, so we -// deliberately do not inject provider headers here. -const PROVIDER_TYPE_DEFAULTS: Record = { - openrouter: { - api: "openai-completions", - baseUrl: "https://openrouter.ai/api/v1", - envVar: "OPENROUTER_API_KEY", - }, - openai: { - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - envVar: "OPENAI_API_KEY", - }, - anthropic: { - api: "anthropic-messages", - baseUrl: "https://api.anthropic.com", - envVar: "ANTHROPIC_API_KEY", - }, - opencode: { - // OpenCode Zen. Some Zen models speak anthropic-messages; override per model - // with `api` when needed. Go models live behind a custom base URL via - // `openai-compatible` (or set `options.baseUrl` to .../zen/go/v1 here). - api: "openai-completions", - baseUrl: "https://opencode.ai/zen/v1", - envVar: "OPENCODE_API_KEY", - }, - "openai-compatible": { - api: "openai-completions", - baseUrl: undefined, - envVar: undefined, - }, - "openai-codex": { - // ChatGPT/OpenAI subscription via OAuth. Auth is a refresh token, not an API - // key, so there is no default env var here. - api: "openai-codex-responses", - baseUrl: "https://chatgpt.com/backend-api", - envVar: undefined, - }, - custom: { - api: undefined, - baseUrl: undefined, - envVar: undefined, - }, -}; - -export function resolvePaseoAgentProviderTypeDefaults( - type: PaseoAgentProviderType, -): PaseoAgentProviderTypeDefault { - return PROVIDER_TYPE_DEFAULTS[type]; -} - const PaseoAgentModelSchema = z .object({ id: z.string().min(1), label: z.string().min(1).optional(), - // Override the provider's wire api for this model (e.g. an anthropic-messages - // model served by an otherwise openai-completions provider like OpenCode Zen). api: z.string().min(1).optional(), reasoning: z.boolean().optional(), contextWindow: z.number().int().positive().optional(), @@ -130,75 +28,42 @@ const PaseoAgentModelSchema = z const PaseoAgentProviderOptionsSchema = z .object({ - // API key. Literal value, an env-var reference like `$OPENROUTER_API_KEY` / - // `${OPENROUTER_API_KEY}`, or a `!command` (resolved by Pi at request time). - // When omitted, known types fall back to their default env var. apiKey: z.string().min(1).optional(), baseUrl: z.string().url().optional(), - // Override the wire api. Required for `custom`; optional elsewhere. api: z.string().min(1).optional(), headers: z.record(z.string(), z.string()).optional(), - // Advanced: send `Authorization: Bearer ` as a header. Only needed for - // endpoints whose wire api doesn't already attach the key. authHeader: z.boolean().optional(), - // Advanced/manual ONLY: a self-supplied OAuth refresh token for `openai-codex` - // (literal, `$ENV`/`${ENV}`, or `!command`). The normal path is the Paseo-owned - // login (`paseo login chatgpt`), which stores the credential for you. refreshToken: z.string().min(1).optional(), - models: z.array(PaseoAgentModelSchema).min(1), + models: z.array(PaseoAgentModelSchema).min(1).optional(), }) .strict(); const PaseoAgentModelProviderSchema = z .object({ - type: z.enum(PROVIDER_TYPES), - options: PaseoAgentProviderOptionsSchema, + type: z.string().min(1), + options: PaseoAgentProviderOptionsSchema.default({}), }) - .strict() - .superRefine((entry, ctx) => { - const defaults = resolvePaseoAgentProviderTypeDefaults(entry.type); - if (!defaults.baseUrl && !entry.options.baseUrl) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["options", "baseUrl"], - message: `Model provider type "${entry.type}" requires options.baseUrl.`, - }); - } - if (!defaults.api && !entry.options.api) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["options", "api"], - message: `Model provider type "${entry.type}" requires options.api.`, - }); - } - // `openai-codex` needs no credential field here: auth comes from the Paseo-owned - // store populated by `paseo login chatgpt`. `options.refreshToken` is an - // optional advanced override. - }); + .strict(); export const PaseoAgentConfigSchema = z .object({ - // Optional default model as "/". defaultModel: z.string().min(1).optional(), - // Optional default agent definition from $PASEO_HOME/agents/.md. defaultAgent: z.string().min(1).optional(), - // Legacy alias for defaultAgent. defaultProfile: z.string().min(1).optional(), - // Model providers keyed by instance name. Multiple entries may share a - // type while pointing at different APIs/base URLs/models. providers: z.record(z.string(), PaseoAgentModelProviderSchema).optional(), }) .strict(); export type PaseoAgentConfig = z.infer; -type PaseoAgentModelProviderEntry = z.infer; +export type PaseoAgentModelProviderEntry = z.infer; +type PiModelConfig = NonNullable[number]; const DEFAULT_CONTEXT_WINDOW = 128_000; const DEFAULT_MAX_TOKENS = 16_384; -interface ResolvedProviderSettings { - baseUrl?: string; - api?: string; +export interface ResolvedProviderSettings { + baseUrl: string; + api: string; apiKey?: string; headers?: Record; authHeader?: boolean; @@ -208,19 +73,39 @@ function entries(config: PaseoAgentConfig): [string, PaseoAgentModelProviderEntr return Object.entries(config.providers ?? {}); } -/** Apply per-type defaults to a raw model provider entry. */ -function resolveProviderSettings(entry: PaseoAgentModelProviderEntry): ResolvedProviderSettings { - const defaults = resolvePaseoAgentProviderTypeDefaults(entry.type); - const apiKey = entry.options.apiKey ?? (defaults.envVar ? `$${defaults.envVar}` : undefined); +function mergeHeaders( + catalogHeaders: Record | undefined, + optionHeaders: Record | undefined, +): Record | undefined { + const headers = { ...catalogHeaders, ...optionHeaders }; + return Object.keys(headers).length > 0 ? headers : undefined; +} + +export function resolvePaseoAgentProviderSettings( + entry: PaseoAgentModelProviderEntry, + catalogEntry: PaseoAgentCatalogEntry = requirePaseoAgentCatalogEntry(entry.type), +): ResolvedProviderSettings { + const apiKey = + catalogEntry.auth.kind === "api_key" + ? (entry.options.apiKey ?? `$${catalogEntry.auth.envVar}`) + : undefined; + const headers = mergeHeaders(catalogEntry.headers, entry.options.headers); return { - baseUrl: entry.options.baseUrl ?? defaults.baseUrl, - api: entry.options.api ?? defaults.api, - apiKey, - headers: entry.options.headers, - authHeader: entry.options.authHeader, + baseUrl: entry.options.baseUrl ?? catalogEntry.baseUrl, + api: entry.options.api ?? catalogEntry.api, + ...(apiKey ? { apiKey } : {}), + ...(headers ? { headers } : {}), + ...(entry.options.authHeader ? { authHeader: entry.options.authHeader } : {}), }; } +export function resolvePaseoAgentProviderModels( + entry: PaseoAgentModelProviderEntry, + catalogEntry: PaseoAgentCatalogEntry = requirePaseoAgentCatalogEntry(entry.type), +): PaseoAgentCatalogModel[] { + return entry.options.models ?? catalogEntry.models; +} + /** * Whether a resolved API-key value is actually configured. Mirrors Pi's config-value * semantics without importing Pi: literals and `!command` values count as present; @@ -240,12 +125,10 @@ function isAuthConfigured(value: string | undefined, env: NodeJS.ProcessEnv): bo return referencedVars.every((name) => Boolean(env[name])); } -/** Encode the Paseo-facing model id from an model provider + Pi model id. */ export function encodePaseoAgentModelId(providerName: string, modelId: string): string { return `${providerName}/${modelId}`; } -/** Parse a Paseo-facing model id back into its model provider + Pi model id. */ export function parsePaseoAgentModelId(modelId: string): PaseoAgentModelReference | null { const slash = modelId.indexOf("/"); if (slash <= 0 || slash === modelId.length - 1) { @@ -254,28 +137,29 @@ export function parsePaseoAgentModelId(modelId: string): PaseoAgentModelReferenc return { provider: modelId.slice(0, slash), id: modelId.slice(slash + 1) }; } -function toPiModels(entry: PaseoAgentModelProviderEntry, settings: ResolvedProviderSettings) { - return entry.options.models.map((model) => { +function toPiModels( + entry: PaseoAgentModelProviderEntry, + settings: ResolvedProviderSettings, +): PiModelConfig[] { + const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); + return resolvePaseoAgentProviderModels(entry, catalogEntry).map((model) => { const api = model.api ?? settings.api; - return { + const piModel: PiModelConfig = { id: model.id, name: model.label ?? model.id, - ...(api ? { api } : {}), reasoning: model.reasoning ?? false, input: ["text"] as ("text" | "image")[], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW, maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS, }; + if (api) { + piModel.api = api; + } + return piModel; }); } -/** - * Map Paseo-owned config into the in-memory model providers the Pi seam expects. - * `openai-codex` entries carry an oauth marker instead of an API key. Their credential - * normally lives in the Paseo-owned store (populated by `paseo login chatgpt`); an - * advanced `options.refreshToken` may supply the user's own token instead. - */ export async function paseoAgentModelProviders( config: PaseoAgentConfig, env: NodeJS.ProcessEnv = process.env, @@ -283,21 +167,23 @@ export async function paseoAgentModelProviders( const providers: PaseoAgentModelProvider[] = []; for (const [name, entry] of entries(config)) { - const settings = resolveProviderSettings(entry); + const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); + const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry); const models = toPiModels(entry, settings); - if (entry.type === "openai-codex") { + if (catalogEntry.auth.kind === "oauth") { const refreshToken = entry.options.refreshToken ? await resolveRefreshTokenExpression(entry.options.refreshToken, env) : undefined; providers.push({ name, config: { - ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), - ...(settings.api ? { api: settings.api } : {}), + baseUrl: settings.baseUrl, + api: settings.api, + ...(settings.headers ? { headers: settings.headers } : {}), models, }, - oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) }, + oauth: { flow: catalogEntry.auth.flow, ...(refreshToken ? { refreshToken } : {}) }, }); continue; } @@ -305,9 +191,9 @@ export async function paseoAgentModelProviders( providers.push({ name, config: { - ...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}), + baseUrl: settings.baseUrl, ...(settings.apiKey ? { apiKey: settings.apiKey } : {}), - ...(settings.api ? { api: settings.api } : {}), + api: settings.api, ...(settings.headers ? { headers: settings.headers } : {}), ...(settings.authHeader ? { authHeader: settings.authHeader } : {}), models, @@ -318,17 +204,17 @@ export async function paseoAgentModelProviders( return providers; } -/** Enumerate configured models as Paseo model definitions (no Pi disk/auth reads). */ export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefinition[] { const models: AgentModelDefinition[] = []; for (const [name, entry] of entries(config)) { - for (const model of entry.options.models) { + const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); + for (const model of resolvePaseoAgentProviderModels(entry, catalogEntry)) { const id = encodePaseoAgentModelId(name, model.id); models.push({ provider: PASEO_AGENT_PROVIDER, id, label: model.label ?? model.id, - description: `${name} · ${model.id}`, + description: `${name} - ${model.id}`, isDefault: config.defaultModel === id, }); } @@ -336,23 +222,17 @@ export function listPaseoAgentModels(config: PaseoAgentConfig): AgentModelDefini return models; } -/** - * An model provider is usable when it has at least one model and auth is configured. - * For API-key types that means a resolvable key (literal, set env var, or command). For - * `openai-codex`, auth comes from the Paseo-owned store (checked via `isOAuthAuthed`, - * keyed by provider instance name) or, as an advanced override, a resolvable - * `options.refreshToken`. - */ export function paseoAgentHasUsableModel( config: PaseoAgentConfig, env: NodeJS.ProcessEnv = process.env, isOAuthAuthed: (providerInstance: string) => boolean = () => false, ): boolean { return entries(config).some(([name, entry]) => { - if (entry.options.models.length === 0) { + const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); + if (resolvePaseoAgentProviderModels(entry, catalogEntry).length === 0) { return false; } - if (entry.type === "openai-codex") { + if (catalogEntry.auth.kind === "oauth") { if ( entry.options.refreshToken && isRefreshTokenExpressionConfigured(entry.options.refreshToken, env) @@ -361,14 +241,10 @@ export function paseoAgentHasUsableModel( } return isOAuthAuthed(name); } - return isAuthConfigured(resolveProviderSettings(entry).apiKey, env); + return isAuthConfigured(resolvePaseoAgentProviderSettings(entry, catalogEntry).apiKey, env); }); } -/** - * Resolve which Pi model to launch: the explicit request is honored as-is; implicit - * default selection only chooses models from the providers actually registered with Pi. - */ export function resolvePaseoAgentModel( config: PaseoAgentConfig, requestedModelId: string | null | undefined, @@ -394,14 +270,15 @@ export function resolvePaseoAgentModel( function paseoAgentModelInventory(config: PaseoAgentConfig): PaseoAgentModelProvider[] { return entries(config).map(([name, entry]) => { - const settings = resolveProviderSettings(entry); + const settings = resolvePaseoAgentProviderSettings(entry); return { name, config: { models: toPiModels(entry, settings) } }; }); } function firstModelId(config: PaseoAgentConfig): string | undefined { for (const [name, entry] of entries(config)) { - const first = entry.options.models[0]; + const catalogEntry = requirePaseoAgentCatalogEntry(entry.type); + const first = resolvePaseoAgentProviderModels(entry, catalogEntry)[0]; if (first) { return encodePaseoAgentModelId(name, first.id); } diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts index ed7f17f02..79f42ab2e 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-credentials.ts @@ -7,12 +7,11 @@ const CREDENTIAL_COMMAND_TIMEOUT_MS = 30_000; // Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env // reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an -// advanced/manual escape hatch for users who already hold their own ChatGPT/Codex -// refresh token; the product path is `paseo login chatgpt`, which performs browser -// OAuth by default and writes a Paseo-owned credential store (see oauth-store.ts). +// advanced/manual escape hatch for users who already hold their own refresh token; +// the normal product path writes a Paseo-owned credential store (see oauth-store.ts). // -// This module deliberately does NOT read any other tool's auth files (Codex CLI, -// OpenCode, Pi, etc.) and imports no Pi runtime code. Token values are never logged. +// This module deliberately does NOT read any other tool's auth files and imports no +// Pi runtime code. Token values are never logged. /** * Resolve a refresh-token expression to its literal value (may run a `!command`). diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts index d4b9df888..e04ec9767 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.posix.test.ts @@ -5,7 +5,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { isPlatform } from "../../../../test-utils/platform.js"; -import { loginAndStoreCodex, loginAndStoreCodexBrowser } from "./oauth-store.js"; +import { loginAndStoreOAuth, storeOAuthCredential } from "./oauth-store.js"; describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => { let home: string; @@ -20,10 +20,11 @@ describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => { rmSync(home, { recursive: true, force: true }); }); - it("stores device-code credentials in a private file", async () => { + it("stores login credentials in a private file", async () => { const login = async () => ({ refresh: "rt-from-login", access: "ac", expires: 123 }); - const { path } = await loginAndStoreCodex({ + const { path } = await loginAndStoreOAuth({ + flow: "paseo-test-oauth", providerInstance: "chatgpt", env, onDeviceCode: () => {}, @@ -33,17 +34,11 @@ describe.skipIf(isPlatform("win32"))("oauth-store POSIX-only", () => { expect(statSync(path).mode & 0o777).toBe(0o600); }); - it("stores browser-login credentials in a private file", async () => { - const login = async (opts: { onAuth: (info: { url: string }) => void }) => { - opts.onAuth({ url: "https://auth.openai.com/oauth/authorize?x=1" }); - return { refresh: "rt-browser", access: "ac", expires: 456 }; - }; - - const { path } = await loginAndStoreCodexBrowser({ + it("stores explicit credentials in a private file", () => { + const { path } = storeOAuthCredential({ providerInstance: "chatgpt", env, - onAuthUrl: () => {}, - login, + credential: { type: "oauth", access: "ac", refresh: "rt", expires: 123 }, }); expect(statSync(path).mode & 0o777).toBe(0o600); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts index 18341b3ad..0429843de 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.test.ts @@ -1,15 +1,43 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import type { OAuthCredentials, OAuthProviderInterface } from "@earendil-works/pi-ai"; +import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { hasStoredOAuthCredential, - loginAndStoreCodex, - loginAndStoreCodexBrowser, + loginAndStoreOAuth, + loginOAuthBrowser, paseoAgentAuthStoragePath, + storeOAuthCredential, } from "./oauth-store.js"; +const TEST_FLOW = "paseo-test-oauth-store"; + +function registerTestOAuthProvider(): void { + const provider: OAuthProviderInterface = { + id: TEST_FLOW, + name: "Paseo Test OAuth", + async login(callbacks): Promise { + callbacks.onDeviceCode({ + userCode: "ABCD-EFGH", + verificationUri: "https://auth.example.test/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }); + return { refresh: "rt-from-registry", access: "ac", expires: 123, accountId: "acct" }; + }, + async refreshToken(credentials): Promise { + return credentials; + }, + getApiKey(credentials): string { + return credentials.access; + }, + }; + registerOAuthProvider(provider); +} + describe("oauth-store", () => { let home: string; let env: NodeJS.ProcessEnv; @@ -19,6 +47,7 @@ describe("oauth-store", () => { env = { PASEO_HOME: home }; }); afterEach(() => { + resetOAuthProviders(); rmSync(home, { recursive: true, force: true }); }); @@ -30,40 +59,50 @@ describe("oauth-store", () => { expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false); }); - it("runs the Pi login helper and persists a Paseo-owned credential", async () => { - const loginCalls: string[] = []; - const deviceCodes: unknown[] = []; - const login = async (opts: { onDeviceCode: (info: unknown) => void }) => { - loginCalls.push("called"); - opts.onDeviceCode({ - userCode: "ABCD-EFGH", - verificationUri: "https://auth.openai.com/codex/device", - intervalSeconds: 5, - expiresInSeconds: 900, - }); - return { refresh: "rt-from-login", access: "ac", expires: 123, accountId: "acct" }; - }; + it("stores a protocol credential with future fields intact", () => { + const { path } = storeOAuthCredential({ + providerInstance: "chatgpt", + env, + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + futureField: { keep: true }, + }, + }); - const { path } = await loginAndStoreCodex({ + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + const stored = JSON.parse(readFileSync(path, "utf8")); + expect(stored.chatgpt).toMatchObject({ + type: "oauth", + refresh: "refresh-token", + futureField: { keep: true }, + }); + }); + + it("runs the Pi registry login flow and persists a Paseo-owned credential", async () => { + registerTestOAuthProvider(); + const deviceCodes: unknown[] = []; + + const { path } = await loginAndStoreOAuth({ + flow: TEST_FLOW, providerInstance: "chatgpt", env, onDeviceCode: (info) => deviceCodes.push(info), - login, }); - expect(loginCalls).toEqual(["called"]); expect(deviceCodes).toEqual([expect.objectContaining({ userCode: "ABCD-EFGH" })]); expect(path).toBe(join(home, "paseo-agent", "auth.json")); - - // Credential persisted to the Paseo-owned store (not any foreign file). expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); const stored = JSON.parse(readFileSync(path, "utf8")); - expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-from-login" }); + expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-from-registry" }); }); it("keys the credential by provider instance name", async () => { const login = async () => ({ refresh: "rt", access: "", expires: 0 }); - await loginAndStoreCodex({ + await loginAndStoreOAuth({ + flow: TEST_FLOW, providerInstance: "work-chatgpt", env, onDeviceCode: () => {}, @@ -73,29 +112,25 @@ describe("oauth-store", () => { expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false); }); - it("browser login surfaces the auth URL and persists a Paseo-owned credential", async () => { + it("browser login surfaces the auth URL and returns a credential without storing it", async () => { const authUrls: Array<[string, string | undefined]> = []; const loginCalls: string[] = []; - // Fake Pi browser-login helper: emits an auth URL, returns credentials. const login = async (opts: { onAuth: (info: { url: string }) => void }) => { loginCalls.push("called"); - opts.onAuth({ url: "https://auth.openai.com/oauth/authorize?x=1" }); + opts.onAuth({ url: "https://auth.example.test/oauth/authorize?x=1" }); return { refresh: "rt-browser", access: "ac", expires: 456, accountId: "acct" }; }; - const { path } = await loginAndStoreCodexBrowser({ - providerInstance: "chatgpt", - env, + const credential = await loginOAuthBrowser({ + flow: TEST_FLOW, onAuthUrl: (url, instructions) => authUrls.push([url, instructions]), login, }); expect(loginCalls).toEqual(["called"]); - expect(authUrls).toEqual([["https://auth.openai.com/oauth/authorize?x=1", undefined]]); - expect(path).toBe(join(home, "paseo-agent", "auth.json")); - expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); - const stored = JSON.parse(readFileSync(path, "utf8")); - expect(stored.chatgpt).toMatchObject({ type: "oauth", refresh: "rt-browser" }); + expect(authUrls).toEqual([["https://auth.example.test/oauth/authorize?x=1", undefined]]); + expect(credential).toMatchObject({ type: "oauth", refresh: "rt-browser" }); + expect(hasStoredOAuthCredential("chatgpt", env)).toBe(false); }); it("browser login falls back to manual code entry only when the callback can't complete", async () => { @@ -104,22 +139,20 @@ describe("oauth-store", () => { prompts.push(message); return "pasted-code"; }; - // Fake helper that cannot complete via callback and invokes onPrompt. const login = async (opts: { onPrompt: (p: { message: string }) => Promise }) => { const code = await opts.onPrompt({ message: "Paste the code:" }); expect(code).toBe("pasted-code"); return { refresh: "rt-manual", access: "", expires: 0 }; }; - await loginAndStoreCodexBrowser({ - providerInstance: "chatgpt", - env, + const credential = await loginOAuthBrowser({ + flow: TEST_FLOW, onAuthUrl: () => {}, promptForCode, login, }); expect(prompts).toEqual(["Paste the code:"]); - expect(hasStoredOAuthCredential("chatgpt", env)).toBe(true); + expect(credential).toMatchObject({ type: "oauth", refresh: "rt-manual" }); }); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts index 95cd1302b..69814c0f6 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/oauth-store.ts @@ -2,41 +2,23 @@ import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { AuthStorage } from "@earendil-works/pi-coding-agent"; -import type { OAuthCredentials } from "@earendil-works/pi-ai"; -import { loginOpenAICodex, loginOpenAICodexDeviceCode } from "@earendil-works/pi-ai/oauth"; +import type { + OAuthCredentials, + OAuthDeviceCodeInfo as PiOAuthDeviceCodeInfo, + OAuthLoginCallbacks, + OAuthSelectPrompt, +} from "@earendil-works/pi-ai"; +import { getOAuthProvider } from "@earendil-works/pi-ai/oauth"; +import type { PaseoAgentOAuthCredential } from "@getpaseo/protocol/messages"; // Paseo-owned OAuth credential store for the Paseo Agent provider. Credentials live -// in a Paseo-controlled file (NOT ~/.pi, ~/.codex, OpenCode, or any other tool's -// store) and are managed through Pi's own AuthStorage, so Pi refreshes tokens and -// persists rotation back into Paseo's file. The login flows reuse Pi's OAuth helpers -// (browser PKCE/callback by default, device-code as a headless fallback) — Paseo does -// not reimplement the OAuth protocol. +// in a Paseo-controlled file and are managed through Pi's own AuthStorage, so Pi +// refreshes tokens and persists rotation back into Paseo's file. Login flows reuse +// Pi's OAuth registry; Paseo does not reimplement OAuth protocols. -export interface CodexDeviceCodeInfo { - userCode: string; - verificationUri: string; - intervalSeconds: number; - expiresInSeconds: number; -} - -type DeviceCodeLogin = (options: { - onDeviceCode: (info: CodexDeviceCodeInfo) => void; - signal?: AbortSignal; -}) => Promise; - -type BrowserLogin = (options: { - onAuth: (info: { url: string; instructions?: string }) => void; - onPrompt: (prompt: { message: string }) => Promise; - onProgress?: (message: string) => void; -}) => Promise; - -export type StoredCodexOAuthCredential = { - type: "oauth"; - access: string; - refresh: string; - expires: number; - accountId?: string; -} & Record; +export type OAuthDeviceCodeInfo = PiOAuthDeviceCodeInfo; +type OAuthLogin = (callbacks: OAuthLoginCallbacks) => Promise; +type OAuthLoginPreference = "browser" | "device"; /** Path to the Paseo-owned auth store. Uses PASEO_HOME; falls back to ~/.paseo. */ export function paseoAgentAuthStoragePath(env: NodeJS.ProcessEnv = process.env): string { @@ -80,12 +62,12 @@ export function hasStoredOAuthCredential( /** * Store a credential obtained by a remote-safe client-side OAuth flow into the - * daemon's Paseo-owned AuthStorage. The caller supplies a stable wire shape, not - * Pi types, and this helper never reads or writes foreign auth files. + * daemon's Paseo-owned AuthStorage. The caller supplies the protocol credential + * shape, and this helper never reads or writes foreign auth files. */ -export function storeCodexOAuthCredential(options: { +export function storeOAuthCredential(options: { providerInstance: string; - credential: StoredCodexOAuthCredential; + credential: PaseoAgentOAuthCredential; env?: NodeJS.ProcessEnv; }): { path: string } { const path = paseoAgentAuthStoragePath(options.env); @@ -94,62 +76,42 @@ export function storeCodexOAuthCredential(options: { return { path }; } -/** - * Run Pi's ChatGPT/Codex device-code OAuth login and persist the resulting credential - * into the Paseo-owned store under `providerInstance`. The `login` dependency defaults - * to Pi's helper and is injectable for tests (no network). Never reads foreign files. - */ -export async function loginAndStoreCodex(options: { +export async function loginAndStoreOAuth(options: { + flow: string; providerInstance: string; - onDeviceCode: (info: CodexDeviceCodeInfo) => void; + onDeviceCode: (info: OAuthDeviceCodeInfo) => void; env?: NodeJS.ProcessEnv; signal?: AbortSignal; - login?: DeviceCodeLogin; + login?: OAuthLogin; }): Promise<{ path: string }> { - const login = options.login ?? (loginOpenAICodexDeviceCode as DeviceCodeLogin); - const credentials = await login({ onDeviceCode: options.onDeviceCode, signal: options.signal }); - const path = paseoAgentAuthStoragePath(options.env); - const authStorage = AuthStorage.create(path); - authStorage.set(options.providerInstance, { type: "oauth", ...credentials }); - return { path }; + const login = resolveOAuthLogin(options.flow, options.login); + const credentials = await login({ + onAuth: () => {}, + onDeviceCode: options.onDeviceCode, + onPrompt: async () => { + throw new Error("OAuth login requested manual input, but no prompt handler is available."); + }, + onSelect: (prompt) => selectOAuthOption(prompt, "device"), + signal: options.signal, + }); + return storeOAuthCredential({ + providerInstance: options.providerInstance, + credential: { type: "oauth", ...credentials }, + env: options.env, + }); } -/** - * Run Pi's ChatGPT/Codex **browser** OAuth login (PKCE + local callback on - * 127.0.0.1:1455) and persist the resulting credential into the Paseo-owned store. - * This is the default, first-class login UX. `onAuthUrl` receives the authorization - * URL (the caller opens it / prints it); `promptForCode` is a fallback used only if - * the browser callback can't complete (manual code paste). The `login` dependency - * defaults to Pi's helper and is injectable for tests. Never reads foreign files. - */ -export async function loginAndStoreCodexBrowser(options: { - providerInstance: string; +export async function loginOAuthBrowser(options: { + flow: string; onAuthUrl: (url: string, instructions?: string) => void; promptForCode?: (message: string) => Promise; onProgress?: (message: string) => void; - env?: NodeJS.ProcessEnv; - login?: BrowserLogin; -}): Promise<{ path: string }> { - const credential = await loginCodexBrowser(options); - const path = paseoAgentAuthStoragePath(options.env); - const authStorage = AuthStorage.create(path); - authStorage.set(options.providerInstance, credential); - return { path }; -} - -/** - * Run Pi's browser OAuth flow and return the credential without storing it locally. - * CLI remote login uses this so the selected daemon remains the owner of persisted auth. - */ -export async function loginCodexBrowser(options: { - onAuthUrl: (url: string, instructions?: string) => void; - promptForCode?: (message: string) => Promise; - onProgress?: (message: string) => void; - login?: BrowserLogin; -}): Promise { - const login = options.login ?? (loginOpenAICodex as BrowserLogin); + login?: OAuthLogin; +}): Promise { + const login = resolveOAuthLogin(options.flow, options.login); const credentials = await login({ onAuth: (info) => options.onAuthUrl(info.url, info.instructions), + onDeviceCode: () => {}, onProgress: options.onProgress, onPrompt: async (prompt) => { if (!options.promptForCode) { @@ -157,6 +119,28 @@ export async function loginCodexBrowser(options: { } return options.promptForCode(prompt.message); }, + onSelect: (prompt) => selectOAuthOption(prompt, "browser"), }); return { type: "oauth", ...credentials }; } + +function resolveOAuthLogin(flow: string, login: OAuthLogin | undefined): OAuthLogin { + if (login) { + return login; + } + const provider = getOAuthProvider(flow); + if (!provider) { + throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`); + } + return (callbacks) => provider.login(callbacks); +} + +function selectOAuthOption( + prompt: OAuthSelectPrompt, + preference: OAuthLoginPreference, +): Promise { + const preferred = prompt.options.find((option) => + option.label.toLowerCase().includes(preference), + ); + return Promise.resolve((preferred ?? prompt.options[0])?.id); +} diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts index 41262f83e..0b68a2c2f 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.test.ts @@ -3,6 +3,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { AuthStorage } from "@earendil-works/pi-coding-agent"; import type { BeforeToolCallContext } from "@earendil-works/pi-agent-core"; +import type { OAuthCredentials, OAuthProviderInterface } from "@earendil-works/pi-ai"; +import { registerOAuthProvider, resetOAuthProviders } from "@earendil-works/pi-ai/oauth"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createToolPermissionPolicy } from "./agent-permissions.js"; @@ -12,17 +14,19 @@ import { createPaseoAgentSession, } from "./pi-services.js"; -function codexModelProvider(): PaseoAgentModelProvider { +const TEST_OAUTH_FLOW = "paseo-test-oauth"; + +function oauthModelProvider(): PaseoAgentModelProvider { return { - name: "chatgpt", + name: "subscription", config: { - baseUrl: "https://chatgpt.com/backend-api", - api: "openai-codex-responses", + baseUrl: "https://example.invalid/oauth", + api: "openai-completions", models: [ { - id: "gpt-5.3-codex", - name: "gpt-5.3-codex", - api: "openai-codex-responses", + id: "oauth-model", + name: "OAuth Model", + api: "openai-completions", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -34,6 +38,27 @@ function codexModelProvider(): PaseoAgentModelProvider { }; } +function registerTestOAuthProvider(): void { + const provider: OAuthProviderInterface = { + id: TEST_OAUTH_FLOW, + name: "Paseo Test OAuth", + async login(): Promise { + return { access: "access-from-login", refresh: "refresh-from-login", expires: Date.now() }; + }, + async refreshToken(credentials): Promise { + return { + ...credentials, + access: "access-from-refresh", + expires: Date.now() + 60_000, + }; + }, + getApiKey(credentials): string { + return credentials.access; + }, + }; + registerOAuthProvider(provider); +} + const FAKE_PROVIDER = "paseo-test-openrouter"; const FAKE_MODEL_ID = "test-model"; @@ -88,6 +113,7 @@ describe("createPaseoAgentSession (no-discovery spike)", () => { }); afterEach(() => { + resetOAuthProviders(); if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; if (originalUserProfile === undefined) delete process.env.USERPROFILE; @@ -260,38 +286,60 @@ describe("createPaseoAgentSession (no-discovery spike)", () => { }); }); - it("registers a codex provider and seeds the advanced refresh-token override", async () => { - const codex = codexModelProvider(); + it("registers an OAuth provider by flow and seeds the advanced refresh-token override", async () => { + registerTestOAuthProvider(); + const oauthProvider = oauthModelProvider(); const { session, modelRegistry } = await createPaseoAgentSession({ cwd, agentDir, - model: { provider: "chatgpt", id: "gpt-5.3-codex" }, - modelProviders: [{ ...codex, oauth: { kind: "openai-codex", refreshToken: "rt-test-only" } }], + model: { provider: "subscription", id: "oauth-model" }, + modelProviders: [ + { ...oauthProvider, oauth: { flow: TEST_OAUTH_FLOW, refreshToken: "rt-test-only" } }, + ], }); - expect(session.model?.provider).toBe("chatgpt"); - expect(modelRegistry.find("chatgpt", "gpt-5.3-codex")?.api).toBe("openai-codex-responses"); + expect(session.model?.provider).toBe("subscription"); + expect(modelRegistry.find("subscription", "oauth-model")?.api).toBe("openai-completions"); const available = modelRegistry.getAvailable(); - expect(available.some((m) => m.provider === "chatgpt" && m.id === "gpt-5.3-codex")).toBe(true); + expect(available.some((m) => m.provider === "subscription" && m.id === "oauth-model")).toBe( + true, + ); }); - it("loads a codex credential from a Paseo-owned AuthStorage (product path)", async () => { - // Simulate the result of `paseo login chatgpt`: a credential already in the store. + it("rejects an OAuth flow that Pi has not registered", async () => { + await expect( + createPaseoAgentSession({ + cwd, + agentDir, + model: { provider: "subscription", id: "oauth-model" }, + modelProviders: [{ ...oauthModelProvider(), oauth: { flow: "missing-flow" } }], + }), + ).rejects.toThrow(/OAuth flow "missing-flow" is not registered/); + }); + + it("loads an OAuth credential from a Paseo-owned AuthStorage", async () => { + registerTestOAuthProvider(); const authPath = join(mkdtempSync(join(tmpdir(), "paseo-agent-auth-")), "auth.json"); const authStorage = AuthStorage.create(authPath); - authStorage.set("chatgpt", { type: "oauth", access: "", refresh: "rt-stored", expires: 0 }); + authStorage.set("subscription", { + type: "oauth", + access: "access-stored", + refresh: "rt-stored", + expires: Date.now() + 60_000, + }); const { modelRegistry } = await createPaseoAgentSession({ cwd, agentDir, authStorage, - model: { provider: "chatgpt", id: "gpt-5.3-codex" }, - // No oauth.refreshToken marker — the credential comes from the Paseo store. - modelProviders: [{ ...codexModelProvider(), oauth: { kind: "openai-codex" } }], + model: { provider: "subscription", id: "oauth-model" }, + modelProviders: [{ ...oauthModelProvider(), oauth: { flow: TEST_OAUTH_FLOW } }], }); const available = modelRegistry.getAvailable(); - expect(available.some((m) => m.provider === "chatgpt" && m.id === "gpt-5.3-codex")).toBe(true); + expect(available.some((m) => m.provider === "subscription" && m.id === "oauth-model")).toBe( + true, + ); rmSync(authPath, { force: true }); }); }); diff --git a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts index bff96d1d5..b35a3598d 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/pi-services.ts @@ -10,8 +10,8 @@ import { createAgentSession, } from "@earendil-works/pi-coding-agent"; import type { BeforeToolCallResult, ThinkingLevel } from "@earendil-works/pi-agent-core"; -import type { ImageContent, TextContent } from "@earendil-works/pi-ai"; -import { openaiCodexOAuthProvider } from "@earendil-works/pi-ai/oauth"; +import type { ImageContent, OAuthProviderInterface, TextContent } from "@earendil-works/pi-ai"; +import { getOAuthProvider } from "@earendil-works/pi-ai/oauth"; import { evaluateToolPermission, type ToolPermissionPolicy } from "./agent-permissions.js"; import type { PaseoComposedPrompt } from "./prompt-profiles.js"; @@ -36,14 +36,8 @@ export type PiProviderConfig = Parameters[1]; type PiAuthData = Parameters[0]; type PiSettings = Parameters[0]; -/** OAuth wiring for an model provider (currently only ChatGPT/Codex). */ export interface PaseoAgentOAuth { - kind: "openai-codex"; - /** - * Advanced/manual override: an already-resolved refresh token to seed into the auth - * store. Omitted on the product path, where the credential already lives in the - * Paseo-owned store (populated by login). - */ + flow: string; refreshToken?: string; } @@ -173,6 +167,14 @@ function installPermissionPolicy( }; } +function resolveOAuthProvider(flow: string): OAuthProviderInterface { + const provider = getOAuthProvider(flow); + if (!provider) { + throw new Error(`Paseo Agent: OAuth flow "${flow}" is not registered by Pi.`); + } + return provider; +} + /** * Create a Pi agent session through the high-level `createAgentSession` API with * every service supplied in-memory and no Pi config discovery. @@ -188,7 +190,7 @@ export async function createPaseoAgentSession( // The product path leaves this empty — the credential is already in the Paseo store. // Empty `access` + `expires: 0` forces a refresh on the first request. for (const provider of options.modelProviders) { - if (provider.oauth?.kind === "openai-codex" && provider.oauth.refreshToken) { + if (provider.oauth?.refreshToken) { authStorage.set(provider.name, { type: "oauth", access: "", @@ -201,10 +203,9 @@ export async function createPaseoAgentSession( const modelRegistry = ModelRegistry.inMemory(authStorage); for (const provider of options.modelProviders) { - const config = - provider.oauth?.kind === "openai-codex" - ? { ...provider.config, oauth: openaiCodexOAuthProvider } - : provider.config; + const config = provider.oauth + ? { ...provider.config, oauth: resolveOAuthProvider(provider.oauth.flow) } + : provider.config; modelRegistry.registerProvider(provider.name, config); } diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index 9719ed312..822428f46 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -48,14 +48,12 @@ export { type SherpaLoaderEnvResolution, } from "./speech/providers/local/sherpa/sherpa-runtime-env.js"; -// Paseo Agent (ChatGPT/Codex) OAuth login + Paseo-owned credential store +// Paseo Agent OAuth login + Paseo-owned credential store export { - loginCodexBrowser, - loginAndStoreCodexBrowser, - loginAndStoreCodex, + loginOAuthBrowser, + loginAndStoreOAuth, paseoAgentAuthStoragePath, - type CodexDeviceCodeInfo, - type StoredCodexOAuthCredential, + type OAuthDeviceCodeInfo, } from "./agent/providers/paseo-agent/oauth-store.js"; // Provider binary resolution diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 8010fb85c..c7c7074ac 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1796,7 +1796,7 @@ export class Session { >, ): Promise { try { - this.createPaseoAgentConfigService().storeChatGptCredential(msg.providerName, msg.credential); + this.createPaseoAgentConfigService().storeOAuthCredential(msg.providerName, msg.credential); await this.refreshPaseoAgentRuntimeSnapshot(); this.emit({ type: "config.paseo_agent.store_chatgpt_credential.response", @@ -1811,7 +1811,7 @@ export class Session { } catch (error) { this.sessionLogger.error( { err: error, providerName: msg.providerName }, - "Failed to store Paseo Agent ChatGPT credential", + "Failed to store Paseo Agent OAuth credential", ); this.emit({ type: "config.paseo_agent.store_chatgpt_credential.response",