mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
feat(paseo-agent): add model provider catalog
Keep persisted provider config structurally open and resolve catalog semantics at runtime.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user