feat(paseo-agent): add model provider catalog

Keep persisted provider config structurally open and resolve catalog semantics at runtime.
This commit is contained in:
Mohamed Boudra
2026-07-02 10:38:17 +02:00
parent 343dd2d10d
commit fce3d14a83
16 changed files with 649 additions and 572 deletions
+13 -13
View File
@@ -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<string> {
}
}
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(
@@ -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",
},
},
});
@@ -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))
@@ -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<string, string>;
compat?: Record<string, unknown>;
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<string, string> = {
"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;
}
@@ -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,
}),
@@ -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<string, string>;
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;
@@ -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<string, unknown>): 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"]);
});
});
@@ -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<PaseoAgentProviderType, PaseoAgentProviderTypeDefault> = {
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 <apiKey>` 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 "<modelProviderName>/<modelId>".
defaultModel: z.string().min(1).optional(),
// Optional default agent definition from $PASEO_HOME/agents/<name>.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<typeof PaseoAgentConfigSchema>;
type PaseoAgentModelProviderEntry = z.infer<typeof PaseoAgentModelProviderSchema>;
export type PaseoAgentModelProviderEntry = z.infer<typeof PaseoAgentModelProviderSchema>;
type PiModelConfig = NonNullable<PaseoAgentModelProvider["config"]["models"]>[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<string, string>;
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<string, string> | undefined,
optionHeaders: Record<string, string> | undefined,
): Record<string, string> | 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);
}
@@ -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`).
@@ -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);
@@ -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<OAuthCredentials> {
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<OAuthCredentials> {
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<string> }) => {
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" });
});
});
@@ -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<OAuthCredentials>;
type BrowserLogin = (options: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: { message: string }) => Promise<string>;
onProgress?: (message: string) => void;
}) => Promise<OAuthCredentials>;
export type StoredCodexOAuthCredential = {
type: "oauth";
access: string;
refresh: string;
expires: number;
accountId?: string;
} & Record<string, unknown>;
export type OAuthDeviceCodeInfo = PiOAuthDeviceCodeInfo;
type OAuthLogin = (callbacks: OAuthLoginCallbacks) => Promise<OAuthCredentials>;
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<string>;
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<string>;
onProgress?: (message: string) => void;
login?: BrowserLogin;
}): Promise<StoredCodexOAuthCredential> {
const login = options.login ?? (loginOpenAICodex as BrowserLogin);
login?: OAuthLogin;
}): Promise<PaseoAgentOAuthCredential> {
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<string | undefined> {
const preferred = prompt.options.find((option) =>
option.label.toLowerCase().includes(preference),
);
return Promise.resolve((preferred ?? prompt.options[0])?.id);
}
@@ -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<OAuthCredentials> {
return { access: "access-from-login", refresh: "refresh-from-login", expires: Date.now() };
},
async refreshToken(credentials): Promise<OAuthCredentials> {
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 });
});
});
@@ -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<ModelRegistry["registerProvider"]>[1];
type PiAuthData = Parameters<typeof AuthStorage.inMemory>[0];
type PiSettings = Parameters<typeof SettingsManager.inMemory>[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);
}
+4 -6
View File
@@ -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
+2 -2
View File
@@ -1796,7 +1796,7 @@ export class Session {
>,
): Promise<void> {
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",