refactor(paseo-agent): derive catalog from Pi

Keep Paseo catalog entries as curated provider refs while deriving model metadata, endpoints, and auth defaults from Pi at runtime.
This commit is contained in:
Mohamed Boudra
2026-07-02 12:40:21 +02:00
parent 2ba7562dff
commit d6e4e5f3ce
8 changed files with 433 additions and 390 deletions
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import { Text, View, type StyleProp, type TextStyle } from "react-native";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Bot, Plus } from "lucide-react-native";
import type {
@@ -46,45 +46,26 @@ interface DynamicProviderIconProps {
color?: string;
}
interface AddSheetClosedState {
kind: "closed";
}
type AddSheetState =
| { kind: "closed" }
| { kind: "picker" }
| {
kind: "form";
entry: PaseoAgentCatalogEntry;
initialName?: string;
lockName: boolean;
returnToPicker: boolean;
};
interface AddSheetPickerState {
kind: "picker";
}
interface AddSheetFormState {
kind: "form";
entry: PaseoAgentCatalogEntry;
initialName?: string;
lockName: boolean;
returnToPicker: boolean;
}
type AddSheetState = AddSheetClosedState | AddSheetPickerState | AddSheetFormState;
interface OAuthIdleState {
status: "idle";
}
interface OAuthAuthorizingState {
status: "authorizing";
authorization: PaseoAgentOAuthStartResult["authorization"];
}
interface OAuthCompletingState {
status: "completing";
authorization: PaseoAgentOAuthStartResult["authorization"];
}
interface OAuthErrorState {
status: "error";
message: string;
authorization: PaseoAgentOAuthStartResult["authorization"];
}
type OAuthState = OAuthIdleState | OAuthAuthorizingState | OAuthCompletingState | OAuthErrorState;
type OAuthState =
| { status: "idle" }
| { status: "authorizing"; authorization: PaseoAgentOAuthStartResult["authorization"] }
| { status: "completing"; authorization: PaseoAgentOAuthStartResult["authorization"] }
| {
status: "error";
message: string;
authorization: PaseoAgentOAuthStartResult["authorization"];
};
interface ProviderNameAndModelsFieldsProps {
entry: PaseoAgentCatalogEntry;
@@ -93,7 +74,6 @@ interface ProviderNameAndModelsFieldsProps {
resetKey: number;
lockName: boolean;
hasCatalogModels: boolean;
modelInputStyle: StyleProp<TextStyle>;
onNameChange: (value: string) => void;
onModelsChange: (value: string) => void;
}
@@ -105,11 +85,6 @@ interface ApiKeyFieldsProps {
onApiKeyChange: (value: string) => void;
}
interface OAuthFieldsProps {
oauthState: OAuthState;
activeAuthorization: PaseoAgentOAuthStartResult["authorization"];
}
interface ProviderFormActionsProps {
saving: boolean;
canSubmit: boolean;
@@ -131,6 +106,14 @@ const PICKER_HEADER: SheetHeader = { title: "Add model provider" };
const CATALOG_UPDATE_MESSAGE = "Update the Paseo daemon to use this.";
const APP_UPDATE_PROVIDER_MESSAGE = "Update the app to use this provider";
function StateBox({ children, testID }: { children: React.ReactNode; testID?: string }) {
return (
<View style={styles.stateBox} testID={testID}>
<Text style={styles.stateText}>{children}</Text>
</View>
);
}
function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) {
const Icon = getProviderIcon(iconKey);
return <Icon size={size} color={color} />;
@@ -335,23 +318,11 @@ function CatalogPickerSubSheet({
}) {
let body: React.ReactNode;
if (error) {
body = (
<View style={styles.stateBox}>
<Text style={styles.stateText}>{error}</Text>
</View>
);
body = <StateBox>{error}</StateBox>;
} else if (isLoading) {
body = (
<View style={styles.stateBox}>
<Text style={styles.stateText}>Loading...</Text>
</View>
);
body = <StateBox>Loading...</StateBox>;
} else if (catalog.length === 0) {
body = (
<View style={styles.stateBox}>
<Text style={styles.stateText}>No providers available.</Text>
</View>
);
body = <StateBox>No providers available.</StateBox>;
} else {
body = (
<View style={styles.list} accessibilityRole="list">
@@ -460,10 +431,11 @@ function ProviderNameAndModelsFields({
resetKey,
lockName,
hasCatalogModels,
modelInputStyle,
onNameChange,
onModelsChange,
}: ProviderNameAndModelsFieldsProps) {
const modelInputStyle = useMemo(() => [styles.formInput, styles.modelsInput], []);
return (
<>
<Text style={styles.formLabel}>Provider name</Text>
@@ -537,23 +509,6 @@ function ApiKeyFields({ auth, apiKey, resetKey, onApiKeyChange }: ApiKeyFieldsPr
);
}
function OAuthFields({ oauthState, activeAuthorization }: OAuthFieldsProps) {
return (
<>
{activeAuthorization ? (
<OAuthAuthorizationPanel authorization={activeAuthorization} />
) : (
<Text style={styles.formHint}>Sign in to connect this provider.</Text>
)}
{oauthState.status === "error" ? (
<Text style={styles.errorText} testID="paseo-agent-oauth-error">
{oauthState.message}
</Text>
) : null}
</>
);
}
function ProviderFormActions({
saving,
canSubmit,
@@ -659,8 +614,6 @@ function PaseoAgentProviderFormSheet({
const [oauthState, setOAuthState] = useState<OAuthState>({ status: "idle" });
const [resetKey, bumpResetKey] = useReducer((key: number) => key + 1, 0);
const entryId = entry?.id;
useEffect(() => {
if (visible && entry) {
setName(initialName ?? entry.id);
@@ -671,7 +624,7 @@ function PaseoAgentProviderFormSheet({
setOAuthState({ status: "idle" });
bumpResetKey();
}
}, [entry, entryId, initialName, visible]);
}, [entry, initialName, visible]);
const header = useMemo<SheetHeader>(
() => ({
@@ -680,8 +633,6 @@ function PaseoAgentProviderFormSheet({
}),
[entry, onBack],
);
const modelInputStyle = useMemo(() => [styles.formInput, styles.modelsInput], []);
const apiKeyAuth = entry ? getPaseoAgentApiKeyAuth(entry) : null;
const oauthAuth = entry ? getPaseoAgentOAuthAuth(entry) : null;
const trimmedName = name.trim();
@@ -789,7 +740,6 @@ function PaseoAgentProviderFormSheet({
resetKey={resetKey}
lockName={lockName}
hasCatalogModels={hasCatalogModels}
modelInputStyle={modelInputStyle}
onNameChange={setName}
onModelsChange={setModels}
/>
@@ -804,7 +754,18 @@ function PaseoAgentProviderFormSheet({
) : null}
{oauthAuth ? (
<OAuthFields oauthState={oauthState} activeAuthorization={activeAuthorization} />
<>
{activeAuthorization ? (
<OAuthAuthorizationPanel authorization={activeAuthorization} />
) : (
<Text style={styles.formHint}>Sign in to connect this provider.</Text>
)}
{oauthState.status === "error" ? (
<Text style={styles.errorText} testID="paseo-agent-oauth-error">
{oauthState.message}
</Text>
) : null}
</>
) : null}
{!apiKeyAuth && !oauthAuth ? (
@@ -922,28 +883,16 @@ export function PaseoAgentSettingsSheet({
let body: React.ReactNode;
if (!supported) {
body = (
<View style={styles.stateBox} testID="paseo-agent-unsupported">
<Text style={styles.stateText}>Update the host to configure Paseo Agent.</Text>
</View>
<StateBox testID="paseo-agent-unsupported">
Update the host to configure Paseo Agent.
</StateBox>
);
} else if (error) {
body = (
<View style={styles.stateBox}>
<Text style={styles.stateText}>{error}</Text>
</View>
);
body = <StateBox>{error}</StateBox>;
} else if (isLoading) {
body = (
<View style={styles.stateBox}>
<Text style={styles.stateText}>Loading...</Text>
</View>
);
body = <StateBox>Loading...</StateBox>;
} else if (providers.length === 0) {
body = (
<View style={styles.stateBox}>
<Text style={styles.stateText}>No providers configured yet.</Text>
</View>
);
body = <StateBox>No providers configured yet.</StateBox>;
} else {
body = (
<View style={styles.list} accessibilityRole="list">
+33 -48
View File
@@ -139,31 +139,32 @@ function requirePaseoAgentCatalogFeature(
} satisfies CommandError;
}
function authField(auth: Record<string, unknown>, field: string): string | undefined {
function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined {
const auth = entry.auth;
const value = auth[field];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function apiKeyEnvVar(entry: PaseoAgentCatalogEntry): string {
const envVar = authField(entry.auth, "envVar");
if (envVar) {
return envVar;
}
function requireAuthField(
entry: PaseoAgentCatalogEntry,
field: string,
description: string,
): string {
const value = authField(entry, field);
if (value) return value;
throw {
code: "UNSUPPORTED_PROVIDER_AUTH",
message: `Provider ${entry.id} is missing an API key environment variable. Update the Paseo daemon to use this command.`,
message: `Provider ${entry.id} is missing ${description}. Update the Paseo daemon to use this command.`,
} satisfies CommandError;
}
function apiKeyEnvVar(entry: PaseoAgentCatalogEntry): string {
return requireAuthField(entry, "envVar", "an API key environment variable");
}
function oauthFlow(entry: PaseoAgentCatalogEntry): string {
const flow = authField(entry.auth, "flow");
if (flow) {
return flow;
}
throw {
code: "UNSUPPORTED_PROVIDER_AUTH",
message: `Provider ${entry.id} is missing an OAuth flow. Update the Paseo daemon to use this command.`,
} satisfies CommandError;
return requireAuthField(entry, "flow", "an OAuth flow");
}
function normalizeModels(rawModels: string[] | undefined): string[] {
@@ -184,7 +185,7 @@ function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] {
}));
}
function selectedModels(
function requireModels(
entry: PaseoAgentCatalogEntry,
options: ProviderAddOptions,
): ProviderModelInput[] {
@@ -192,14 +193,8 @@ function selectedModels(
if (modelIds.length > 0) {
return modelIds.map((id) => ({ id }));
}
return catalogModels(entry);
}
function requireModels(
entry: PaseoAgentCatalogEntry,
options: ProviderAddOptions,
): ProviderModelInput[] {
const models = selectedModels(entry, options);
const models = catalogModels(entry);
if (models.length > 0) {
return models;
}
@@ -243,7 +238,6 @@ async function selectCatalogEntry(
async function resolveEntry(
id: string | undefined,
catalog: PaseoAgentCatalogEntry[],
client: ProviderAddClient,
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentCatalogEntry> {
if (!id) {
@@ -255,14 +249,9 @@ async function resolveEntry(
return entry;
}
const result = await client.setPaseoAgentProvider({
name: id,
providerType: id,
options: { models: [{ id: "placeholder" }] },
});
throw {
code: "UNKNOWN_PROVIDER",
message: result.error ?? `Unknown model provider type "${id}".`,
message: `Unknown model provider type "${id}".`,
} satisfies CommandError;
}
@@ -320,28 +309,21 @@ async function resolveApiKey(
} satisfies CommandError;
}
const hint = authField(entry.auth, "hint");
const keyUrl = authField(entry.auth, "keyUrl");
const hint = authField(entry, "hint");
const keyUrl = authField(entry, "keyUrl");
if (hint) {
dependencies.write(hint);
}
if (keyUrl) {
dependencies.write(`API key URL: ${keyUrl}`);
}
const placeholder = authField(entry.auth, "placeholder") ?? "API key";
const placeholder = authField(entry, "placeholder") ?? "API key";
const value = await dependencies.promptSecret(
`Enter ${placeholder} (leave empty to use $${envVar}):`,
);
return value || `$${envVar}`;
}
function browserOpenError(): CommandError {
return {
code: "BROWSER_OPEN_FAILED",
message: "Browser could not be opened.",
};
}
function isBrowserOpenError(error: unknown): boolean {
return (
typeof error === "object" &&
@@ -415,7 +397,10 @@ async function runBrowserOAuth(
onAuthUrl: (url, instructions) => {
const opened = dependencies.openBrowser(url);
if (!opened) {
throw browserOpenError();
throw {
code: "BROWSER_OPEN_FAILED",
message: "Browser could not be opened.",
} satisfies CommandError;
}
dependencies.write(instructions ?? "Opening your browser to authorize Paseo.");
dependencies.write(` ${url}`);
@@ -500,23 +485,23 @@ export async function runAddCommand(
message: catalogResult.error,
} satisfies CommandError;
}
const entry = await resolveEntry(id, catalogResult.catalog, client, deps);
const entry = await resolveEntry(id, catalogResult.catalog, deps);
const name = options.name?.trim() || entry.id;
const provider = await configureProvider(client, entry, name, options, deps);
let outputProvider = provider;
if (entry.auth.kind === "oauth") {
const auth = await authenticateOAuthProvider(client, entry, name, options, deps);
outputProvider = auth ? { ...provider, auth } : provider;
} else if (entry.auth.kind !== "api_key") {
if (entry.auth.kind !== "api_key" && entry.auth.kind !== "oauth") {
throw {
code: "UNSUPPORTED_PROVIDER_AUTH",
message: `Provider ${entry.label} uses an auth type this CLI does not understand. Update the Paseo daemon to use this command.`,
} satisfies CommandError;
}
const auth =
entry.auth.kind === "oauth"
? await authenticateOAuthProvider(client, entry, name, options, deps)
: provider.auth;
return {
type: "single",
data: toConfiguredItem(outputProvider, entry),
data: toConfiguredItem(auth ? { ...provider, auth } : provider, entry),
schema: providerConfiguredSchema,
};
} finally {
@@ -1,80 +1,56 @@
export interface PaseoAgentCatalogModel {
[key: string]: unknown;
id: string;
label?: string;
api?: string;
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
}
import type { KnownProvider } from "@earendil-works/pi-ai";
export interface PaseoAgentApiKeyAuth {
[key: string]: unknown;
export interface PaseoAgentKeyAuthHint {
kind: "api_key";
envVar: string;
envVar?: string;
keyUrl?: string;
placeholder?: string;
hint?: string;
}
export interface PaseoAgentOAuthAuth {
[key: string]: unknown;
export interface PaseoAgentOAuthAuthHint {
kind: "oauth";
flow: string;
flow?: string;
}
export type PaseoAgentCatalogAuth = PaseoAgentApiKeyAuth | PaseoAgentOAuthAuth;
export type PaseoAgentCatalogAuthHint = PaseoAgentKeyAuthHint | PaseoAgentOAuthAuthHint;
export interface PaseoAgentCatalogEntry {
[key: string]: unknown;
export interface PaseoAgentCatalogRef {
id: string;
piProvider: KnownProvider;
label: string;
iconName?: string;
docsUrl?: string;
api: string;
baseUrl: string;
headers?: Record<string, string>;
compat?: Record<string, unknown>;
auth: PaseoAgentCatalogAuth;
models: PaseoAgentCatalogModel[];
auth?: PaseoAgentCatalogAuthHint;
defaultModels?: boolean;
}
export const PASEO_AGENT_PROVIDER_CATALOG = [
{
id: "openrouter",
piProvider: "openrouter",
label: "OpenRouter",
api: "openai-completions",
baseUrl: "https://openrouter.ai/api/v1",
auth: { kind: "api_key", envVar: "OPENROUTER_API_KEY" },
models: [],
defaultModels: false,
},
{
id: "chatgpt",
piProvider: "openai-codex",
label: "ChatGPT",
iconName: "openai",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
auth: { kind: "oauth", flow: "openai-codex" },
models: [{ id: "gpt-5.4-mini", reasoning: true }],
},
{
id: "kimi",
piProvider: "kimi-coding",
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",
piProvider: "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[];
] as const satisfies readonly PaseoAgentCatalogRef[];
const PASEO_AGENT_PROVIDER_ALIASES: Record<string, string> = {
"openai-codex": "chatgpt",
@@ -82,7 +58,7 @@ const PASEO_AGENT_PROVIDER_ALIASES: Record<string, string> = {
export function resolvePaseoAgentCatalogEntry(
providerType: string,
): PaseoAgentCatalogEntry | undefined {
): PaseoAgentCatalogRef | undefined {
const canonicalId = PASEO_AGENT_PROVIDER_ALIASES[providerType] ?? providerType;
return PASEO_AGENT_PROVIDER_CATALOG.find((entry) => entry.id === canonicalId);
}
@@ -95,7 +71,7 @@ export function unknownPaseoAgentProviderTypeMessage(providerType: string): stri
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 {
export function requirePaseoAgentCatalogEntry(providerType: string): PaseoAgentCatalogRef {
const entry = resolvePaseoAgentCatalogEntry(providerType);
if (!entry) {
throw new Error(unknownPaseoAgentProviderTypeMessage(providerType));
@@ -1,6 +1,7 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getModels } from "@earendil-works/pi-ai";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
@@ -9,6 +10,17 @@ import { PaseoAgentConfigService } from "./config-service.js";
import { PaseoAgentConfigSchema } from "./config.js";
import { paseoAgentAuthStoragePath, storeOAuthCredential } from "./oauth-store.js";
function piCatalogModels(provider: Parameters<typeof getModels>[0]) {
return getModels(provider).map((model) => ({
id: model.id,
label: model.name,
api: model.api,
reasoning: model.reasoning,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
}));
}
describe("PaseoAgentConfigService", () => {
let home: string;
@@ -156,6 +168,27 @@ describe("PaseoAgentConfigService", () => {
expect(loadPersistedConfig(home).agents?.paseo?.providers?.chatgpt?.type).toBe("chatgpt");
});
test("does not persist Pi catalog defaults as instance model overrides", () => {
const service = new PaseoAgentConfigService({
paseoHome: home,
logger: createTestLogger(),
});
const chatgptCatalog = service.getCatalog().find((entry) => entry.id === "chatgpt");
if (!chatgptCatalog) {
throw new Error("missing chatgpt catalog entry");
}
service.setProvider({
name: "chatgpt",
providerType: "chatgpt",
options: { models: chatgptCatalog.models },
});
expect(loadPersistedConfig(home).agents?.paseo?.providers?.chatgpt?.options.models).toBe(
undefined,
);
});
test("preserves shared config fields when writing agents.paseo", () => {
const logger = createTestLogger();
savePersistedConfig(
@@ -258,7 +291,7 @@ describe("PaseoAgentConfigService", () => {
expect.objectContaining({
name: "chatgpt",
providerType: "chatgpt",
models: [{ id: "gpt-5.4-mini", reasoning: true }],
models: piCatalogModels("openai-codex"),
auth: { kind: "oauth", configured: true, source: "stored" },
available: true,
}),
@@ -11,16 +11,17 @@ import {
type PersistedConfig,
} from "../../../persisted-config.js";
import {
isPaseoAgentDefaultModelSelection,
PaseoAgentConfigSchema,
paseoAgentCatalogManifests,
type PaseoAgentConfig,
type PaseoAgentCatalogManifestEntry,
type PaseoAgentProviderModelConfig,
resolvePaseoAgentCatalogAuth,
resolvePaseoAgentProviderModels,
resolvePaseoAgentProviderSettings,
} from "./config.js";
import {
PASEO_AGENT_PROVIDER_CATALOG,
type PaseoAgentCatalogEntry,
requirePaseoAgentCatalogEntry,
} from "./catalog.js";
import { requirePaseoAgentCatalogEntry, type PaseoAgentCatalogRef } from "./catalog.js";
import {
getStoredOAuthCredentialState,
storeOAuthCredential,
@@ -45,14 +46,7 @@ interface SetProviderInput {
api?: string;
headers?: Record<string, string>;
authHeader?: boolean;
models?: Array<{
id: string;
label?: string;
api?: string;
reasoning?: boolean;
contextWindow?: number;
maxTokens?: number;
}>;
models?: PaseoAgentProviderModelConfig[];
};
}
@@ -91,16 +85,27 @@ function authStateForApiKey(
return { kind: "api_key", configured: true, source: "literal" };
}
function copyCatalogEntry(entry: PaseoAgentCatalogEntry): PaseoAgentCatalogEntry {
function copyCatalogEntry(entry: PaseoAgentCatalogManifestEntry): PaseoAgentCatalogManifestEntry {
return {
...entry,
...(entry.headers ? { headers: { ...entry.headers } } : {}),
...(entry.compat ? { compat: { ...entry.compat } } : {}),
auth: { ...entry.auth },
models: entry.models.map((model) => ({ ...model })),
};
}
function providerOptionsForPersist(
options: SetProviderInput["options"],
catalogEntry: PaseoAgentCatalogRef,
): SetProviderInput["options"] {
if (!isPaseoAgentDefaultModelSelection(options.models, catalogEntry)) {
return options;
}
const rest = { ...options };
delete rest.models;
return rest;
}
function oauthBindingForSettings(
flow: string,
settings: ReturnType<typeof resolvePaseoAgentProviderSettings>,
@@ -125,10 +130,11 @@ function redactedProviders(
): RedactedPaseoAgentProviderConfig[] {
return Object.entries(config.providers ?? {}).map(([name, entry]) => {
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
const models = resolvePaseoAgentProviderModels(entry, catalogEntry);
let auth: PaseoAgentProviderAuthState;
if (catalogEntry.auth.kind === "oauth") {
if (authManifest.kind === "oauth") {
const hasRefreshToken =
entry.options.refreshToken &&
isRefreshTokenExpressionConfigured(entry.options.refreshToken, env);
@@ -138,7 +144,7 @@ function redactedProviders(
const stored = getStoredOAuthCredentialState(
name,
env,
oauthBindingForSettings(catalogEntry.auth.flow, settings),
oauthBindingForSettings(authManifest.flow, settings),
);
if (stored.present && stored.bindingMatches) {
auth = { kind: "oauth", configured: true, source: "stored" };
@@ -154,7 +160,7 @@ function redactedProviders(
}
}
} else {
auth = authStateForApiKey(entry.options.apiKey, catalogEntry.auth.envVar, env);
auth = authStateForApiKey(entry.options.apiKey, authManifest.envVar, env);
}
const provider: RedactedPaseoAgentProviderConfig = {
name,
@@ -196,8 +202,8 @@ export class PaseoAgentConfigService {
this.onConfigChanged = options.onConfigChanged;
}
getCatalog(): PaseoAgentCatalogEntry[] {
return PASEO_AGENT_PROVIDER_CATALOG.map(copyCatalogEntry);
getCatalog(): PaseoAgentCatalogManifestEntry[] {
return paseoAgentCatalogManifests().map(copyCatalogEntry);
}
getProviders(): { defaultModel: string | null; providers: RedactedPaseoAgentProviderConfig[] } {
@@ -217,7 +223,7 @@ export class PaseoAgentConfigService {
...current.providers,
[input.name]: {
type: catalogEntry.id,
options: input.options,
options: providerOptionsForPersist(input.options, catalogEntry),
},
},
}),
@@ -247,11 +253,12 @@ export class PaseoAgentConfigService {
throw new Error(`Paseo Agent provider '${providerName}' is not configured.`);
}
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
if (catalogEntry.auth.kind !== "oauth") {
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
if (authManifest.kind !== "oauth") {
throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`);
}
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
return oauthBindingForSettings(catalogEntry.auth.flow, settings);
return oauthBindingForSettings(authManifest.flow, settings);
}
storeOAuthCredential(
@@ -265,14 +272,15 @@ export class PaseoAgentConfigService {
throw new Error(`Paseo Agent provider '${providerName}' is not configured.`);
}
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
if (catalogEntry.auth.kind !== "oauth") {
const authManifest = resolvePaseoAgentCatalogAuth(catalogEntry);
if (authManifest.kind !== "oauth") {
throw new Error(`Paseo Agent provider '${providerName}' does not use OAuth.`);
}
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
storeOAuthCredential({
providerInstance: providerName,
credential,
binding: binding ?? oauthBindingForSettings(catalogEntry.auth.flow, settings),
binding: binding ?? oauthBindingForSettings(authManifest.flow, settings),
env: this.env,
});
this.onConfigChanged?.(config);
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { getModels } from "@earendil-works/pi-ai";
import {
PaseoAgentConfigSchema,
@@ -11,6 +12,10 @@ import {
type PaseoAgentConfig,
} from "./config.js";
function piModelIds(provider: Parameters<typeof getModels>[0]): string[] {
return getModels(provider).map((model) => model.id);
}
function configWith(overrides?: Partial<PaseoAgentConfig>): PaseoAgentConfig {
return PaseoAgentConfigSchema.parse({
providers: {
@@ -107,7 +112,9 @@ describe("listPaseoAgentModels", () => {
const models = listPaseoAgentModels(
PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }),
);
expect(models.map((m) => m.id)).toEqual(["chatgpt/gpt-5.4-mini"]);
expect(models.map((m) => m.id)).toEqual(
piModelIds("openai-codex").map((modelId) => `chatgpt/${modelId}`),
);
});
it("marks the configured default model", () => {
@@ -153,7 +160,7 @@ describe("paseoAgentModelProviders", () => {
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]?.headers).toEqual({ "User-Agent": "KimiCLI/1.5" });
expect(provider.config.models?.[0]?.api).toBe("anthropic-messages");
});
@@ -179,7 +186,7 @@ describe("paseoAgentModelProviders", () => {
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.4-mini");
expect(provider.config.models?.map((model) => model.id)).toEqual(piModelIds("openai-codex"));
});
it("lets instance models override catalog default models", async () => {
@@ -205,7 +212,7 @@ describe("paseoAgentModelProviders", () => {
}),
);
expect(provider.oauth).toEqual({ flow: "openai-codex" });
expect(provider.config.models?.[0]?.id).toBe("gpt-5.4-mini");
expect(provider.config.models?.map((model) => model.id)).toEqual(piModelIds("openai-codex"));
});
it("rejects unknown provider types at runtime with known ids", async () => {
@@ -295,7 +302,7 @@ describe("resolvePaseoAgentModel", () => {
PaseoAgentConfigSchema.parse({ providers: { chatgpt: { type: "chatgpt" } } }),
null,
),
).toEqual({ provider: "chatgpt", id: "gpt-5.4-mini" });
).toEqual({ provider: "chatgpt", id: piModelIds("openai-codex")[0] });
});
it("ignores an implicit default whose provider is not registered", () => {
@@ -1,3 +1,6 @@
import { findEnvKeys, getModels, type Api, type Model } from "@earendil-works/pi-ai";
import { getOAuthProvider } from "@earendil-works/pi-ai/oauth";
import type { PaseoAgentCatalogEntry as PaseoAgentCatalogManifestEntry } from "@getpaseo/protocol/messages";
import { z } from "zod";
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
@@ -8,9 +11,9 @@ import {
import type { OAuthCredentialBinding } from "./oauth-store.js";
import type { PaseoAgentModelProvider, PaseoAgentModelReference } from "./pi-services.js";
import {
PASEO_AGENT_PROVIDER_CATALOG,
requirePaseoAgentCatalogEntry,
type PaseoAgentCatalogEntry,
type PaseoAgentCatalogModel,
type PaseoAgentCatalogRef,
} from "./catalog.js";
import { findEnvReferences } from "./env-references.js";
@@ -57,14 +60,33 @@ export const PaseoAgentConfigSchema = z
export type PaseoAgentConfig = z.infer<typeof PaseoAgentConfigSchema>;
export type PaseoAgentModelProviderEntry = z.infer<typeof PaseoAgentModelProviderSchema>;
type PiModelConfig = NonNullable<PaseoAgentModelProvider["config"]["models"]>[number];
export type PaseoAgentProviderModelConfig = z.infer<typeof PaseoAgentModelSchema>;
export type { PaseoAgentCatalogManifestEntry };
type PiModel = Model<Api>;
type PiModelConfig = NonNullable<PaseoAgentModelProvider["config"]["models"]>[number];
type ProviderOptions = PaseoAgentModelProviderEntry["options"];
const DEFAULT_INPUT: PiModelConfig["input"] = ["text"];
const ZERO_COST: PiModelConfig["cost"] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 16_384;
const DEFAULT_MODEL_FIELDS = [
"id",
"label",
"api",
"reasoning",
"contextWindow",
"maxTokens",
] as const;
type ResolvedCatalogAuth =
| { kind: "api_key"; envVar: string; keyUrl?: string; placeholder?: string; hint?: string }
| { kind: "oauth"; flow: string };
export interface ResolvedProviderSettings {
baseUrl: string;
api: string;
api: Api;
apiKey?: string;
headers?: Record<string, string>;
authHeader?: boolean;
@@ -74,37 +96,131 @@ function entries(config: PaseoAgentConfig): [string, PaseoAgentModelProviderEntr
return Object.entries(config.providers ?? {});
}
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;
function getPaseoAgentPiModels(catalogEntry: PaseoAgentCatalogRef): PiModel[] {
return getModels(catalogEntry.piProvider);
}
function requirePaseoAgentPrimaryModel(catalogEntry: PaseoAgentCatalogRef): PiModel {
const first = getPaseoAgentPiModels(catalogEntry)[0];
if (!first) {
throw new Error(`Paseo Agent provider "${catalogEntry.id}" has no Pi models.`);
}
return first;
}
function defaultPaseoAgentPiModels(catalogEntry: PaseoAgentCatalogRef): PiModel[] {
return catalogEntry.defaultModels === false ? [] : getPaseoAgentPiModels(catalogEntry);
}
export function resolvePaseoAgentCatalogAuth(
catalogEntry: PaseoAgentCatalogRef,
): ResolvedCatalogAuth {
if (catalogEntry.auth?.kind === "oauth") {
return { kind: "oauth", flow: catalogEntry.auth.flow ?? catalogEntry.piProvider };
}
if (!catalogEntry.auth && getOAuthProvider(catalogEntry.piProvider)) {
return { kind: "oauth", flow: catalogEntry.piProvider };
}
const envVar =
catalogEntry.auth?.kind === "api_key"
? catalogEntry.auth.envVar
: findEnvKeys(catalogEntry.piProvider)?.[0];
if (!envVar) {
throw new Error(`Paseo Agent provider "${catalogEntry.id}" has no auth source.`);
}
return {
kind: "api_key",
envVar,
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.keyUrl
? { keyUrl: catalogEntry.auth.keyUrl }
: {}),
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.placeholder
? { placeholder: catalogEntry.auth.placeholder }
: {}),
...(catalogEntry.auth?.kind === "api_key" && catalogEntry.auth.hint
? { hint: catalogEntry.auth.hint }
: {}),
};
}
export function resolvePaseoAgentProviderSettings(
entry: PaseoAgentModelProviderEntry,
catalogEntry: PaseoAgentCatalogEntry = requirePaseoAgentCatalogEntry(entry.type),
catalogEntry: PaseoAgentCatalogRef = 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);
const primaryModel = requirePaseoAgentPrimaryModel(catalogEntry);
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
const apiKey = auth.kind === "api_key" ? (entry.options.apiKey ?? `$${auth.envVar}`) : undefined;
return {
baseUrl: entry.options.baseUrl ?? catalogEntry.baseUrl,
api: entry.options.api ?? catalogEntry.api,
baseUrl: entry.options.baseUrl ?? primaryModel.baseUrl,
api: entry.options.api ?? primaryModel.api,
...(apiKey ? { apiKey } : {}),
...(headers ? { headers } : {}),
...(entry.options.headers ? { headers: entry.options.headers } : {}),
...(entry.options.authHeader ? { authHeader: entry.options.authHeader } : {}),
};
}
function toCatalogModel(model: PiModel): PaseoAgentProviderModelConfig {
return {
id: model.id,
label: model.name,
api: model.api,
reasoning: model.reasoning,
contextWindow: model.contextWindow,
maxTokens: model.maxTokens,
};
}
export function resolvePaseoAgentProviderModels(
entry: PaseoAgentModelProviderEntry,
catalogEntry: PaseoAgentCatalogEntry = requirePaseoAgentCatalogEntry(entry.type),
): PaseoAgentCatalogModel[] {
return entry.options.models ?? catalogEntry.models;
catalogEntry: PaseoAgentCatalogRef = requirePaseoAgentCatalogEntry(entry.type),
): PaseoAgentProviderModelConfig[] {
return entry.options.models ?? defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel);
}
export function isPaseoAgentDefaultModelSelection(
models: PaseoAgentProviderModelConfig[] | undefined,
catalogEntry: PaseoAgentCatalogRef,
): boolean {
if (!models) {
return false;
}
const defaults = defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel);
if (models.length !== defaults.length || defaults.length === 0) {
return false;
}
return models.every((model, index) => {
const defaultModel = defaults[index];
return DEFAULT_MODEL_FIELDS.every((field) => model[field] === defaultModel?.[field]);
});
}
export function paseoAgentCatalogManifests(): PaseoAgentCatalogManifestEntry[] {
return PASEO_AGENT_PROVIDER_CATALOG.map((catalogEntry: PaseoAgentCatalogRef) => {
const primaryModel = requirePaseoAgentPrimaryModel(catalogEntry);
const manifest: PaseoAgentCatalogManifestEntry = {
id: catalogEntry.id,
label: catalogEntry.label,
api: primaryModel.api,
baseUrl: primaryModel.baseUrl,
auth: resolvePaseoAgentCatalogAuth(catalogEntry),
models: defaultPaseoAgentPiModels(catalogEntry).map(toCatalogModel),
};
if (catalogEntry.iconName) {
manifest.iconName = catalogEntry.iconName;
}
if (catalogEntry.docsUrl) {
manifest.docsUrl = catalogEntry.docsUrl;
}
if (primaryModel.headers) {
manifest.headers = { ...primaryModel.headers };
}
return manifest;
});
}
/**
@@ -138,26 +254,65 @@ export function parsePaseoAgentModelId(modelId: string): PaseoAgentModelReferenc
return { provider: modelId.slice(0, slash), id: modelId.slice(slash + 1) };
}
function applyModelOverrides(
model: PiModel,
options: ProviderOptions,
override?: PaseoAgentProviderModelConfig,
): PiModelConfig {
return {
id: override?.id ?? model.id,
name: override?.label ?? model.name,
api: override?.api ?? options.api ?? model.api,
baseUrl: options.baseUrl ?? model.baseUrl,
reasoning: override?.reasoning ?? model.reasoning,
...(model.thinkingLevelMap ? { thinkingLevelMap: model.thinkingLevelMap } : {}),
input: model.input,
cost: model.cost,
contextWindow: override?.contextWindow ?? model.contextWindow,
maxTokens: override?.maxTokens ?? model.maxTokens,
...(model.headers ? { headers: { ...model.headers } } : {}),
...(model.compat ? { compat: model.compat } : {}),
};
}
function customModelFromOptions(
model: PaseoAgentProviderModelConfig,
options: ProviderOptions,
fallback: PiModel,
): PiModelConfig {
return {
id: model.id,
name: model.label ?? model.id,
api: model.api ?? options.api ?? fallback.api,
baseUrl: options.baseUrl ?? fallback.baseUrl,
reasoning: model.reasoning ?? false,
input: fallback.input ?? DEFAULT_INPUT,
cost: ZERO_COST,
contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
...(fallback.headers ? { headers: { ...fallback.headers } } : {}),
};
}
function toPiModels(
entry: PaseoAgentModelProviderEntry,
settings: ResolvedProviderSettings,
catalogEntry: PaseoAgentCatalogRef,
): PiModelConfig[] {
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
return resolvePaseoAgentProviderModels(entry, catalogEntry).map((model) => {
const api = model.api ?? settings.api;
const piModel: PiModelConfig = {
id: model.id,
name: model.label ?? model.id,
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;
const piModels = getPaseoAgentPiModels(catalogEntry);
const piModelsById = new Map(piModels.map((model) => [model.id, model]));
const selectedModels = entry.options.models;
if (!selectedModels) {
return defaultPaseoAgentPiModels(catalogEntry).map((model) =>
applyModelOverrides(model, entry.options),
);
}
const fallback = requirePaseoAgentPrimaryModel(catalogEntry);
return selectedModels.map((model) => {
const piModel = piModelsById.get(model.id);
return piModel
? applyModelOverrides(piModel, entry.options, model)
: customModelFromOptions(model, entry.options, fallback);
});
}
@@ -169,22 +324,24 @@ export async function paseoAgentModelProviders(
for (const [name, entry] of entries(config)) {
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
const settings = resolvePaseoAgentProviderSettings(entry, catalogEntry);
const models = toPiModels(entry, settings);
const models = toPiModels(entry, catalogEntry);
const providerConfig = {
baseUrl: settings.baseUrl,
api: settings.api,
...(settings.headers ? { headers: settings.headers } : {}),
models,
};
if (catalogEntry.auth.kind === "oauth") {
if (auth.kind === "oauth") {
const refreshToken = entry.options.refreshToken
? await resolveRefreshTokenExpression(entry.options.refreshToken, env)
: undefined;
providers.push({
name,
config: {
baseUrl: settings.baseUrl,
api: settings.api,
...(settings.headers ? { headers: settings.headers } : {}),
models,
},
oauth: { flow: catalogEntry.auth.flow, ...(refreshToken ? { refreshToken } : {}) },
config: providerConfig,
oauth: { flow: auth.flow, ...(refreshToken ? { refreshToken } : {}) },
});
continue;
}
@@ -192,12 +349,9 @@ export async function paseoAgentModelProviders(
providers.push({
name,
config: {
baseUrl: settings.baseUrl,
...providerConfig,
...(settings.apiKey ? { apiKey: settings.apiKey } : {}),
api: settings.api,
...(settings.headers ? { headers: settings.headers } : {}),
...(settings.authHeader ? { authHeader: settings.authHeader } : {}),
models,
},
});
}
@@ -231,10 +385,13 @@ export function paseoAgentHasUsableModel(
): boolean {
return entries(config).some(([name, entry]) => {
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
if (resolvePaseoAgentProviderModels(entry, catalogEntry).length === 0) {
const models = resolvePaseoAgentProviderModels(entry, catalogEntry);
if (models.length === 0) {
return false;
}
if (catalogEntry.auth.kind === "oauth") {
const auth = resolvePaseoAgentCatalogAuth(catalogEntry);
if (auth.kind === "oauth") {
if (
entry.options.refreshToken &&
isRefreshTokenExpressionConfigured(entry.options.refreshToken, env)
@@ -242,7 +399,7 @@ export function paseoAgentHasUsableModel(
return true;
}
return isOAuthAuthed(name, {
flow: catalogEntry.auth.flow,
flow: auth.flow,
baseUrl: resolvePaseoAgentProviderSettings(entry, catalogEntry).baseUrl,
});
}
@@ -275,8 +432,8 @@ export function resolvePaseoAgentModel(
function paseoAgentModelInventory(config: PaseoAgentConfig): PaseoAgentModelProvider[] {
return entries(config).map(([name, entry]) => {
const settings = resolvePaseoAgentProviderSettings(entry);
return { name, config: { models: toPiModels(entry, settings) } };
const catalogEntry = requirePaseoAgentCatalogEntry(entry.type);
return { name, config: { models: toPiModels(entry, catalogEntry) } };
});
}