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
+37 -109
View File
@@ -21,30 +21,31 @@ instance and the `openai/gpt-4o-mini` model exposed by that instance.
## Provider catalog
Paseo Agent model providers are catalog-driven. `catalog.ts` is the source of truth for
which provider types the daemon knows how to configure. A catalog entry is data only:
id, label, wire API, base URL, optional headers, auth metadata, and optional default
models. The app picker, CLI setup, config RPCs, runtime provider resolution, and auth
state all use that same data.
Paseo Agent model providers are catalog-driven, but Paseo does not copy Pi's provider
registry. `catalog.ts` is only the curated Paseo surface: catalog id, Pi provider id,
label, icon, default-model policy, and auth hints Pi cannot infer. Pi supplies the wire
API, base URL, headers, model ids, context windows, token limits, costs, reasoning flags,
thinking maps, and OAuth registry data at runtime.
The current catalog contains four entries:
| id | label | api | base URL | auth |
| ------------- | ---------------- | ------------------------ | --------------------------------- | ----------------------------- |
| `openrouter` | OpenRouter | `openai-completions` | `https://openrouter.ai/api/v1` | API key, `OPENROUTER_API_KEY` |
| `chatgpt` | ChatGPT | `openai-codex-responses` | `https://chatgpt.com/backend-api` | OAuth, `openai-codex` flow |
| `kimi` | Kimi Coding Plan | `anthropic-messages` | `https://api.kimi.com/coding` | API key, `KIMI_API_KEY` |
| `opencode-go` | OpenCode Go | `openai-completions` | `https://opencode.ai/zen/go/v1` | API key, `OPENCODE_API_KEY` |
| id | Pi provider | default models | auth source |
| ------------- | -------------- | -------------- | --------------------------------------------- |
| `openrouter` | `openrouter` | none | Pi env key `OPENROUTER_API_KEY` |
| `chatgpt` | `openai-codex` | Pi full list | Pi OAuth registry (`openai-codex`) |
| `kimi` | `kimi-coding` | Pi full list | Paseo hint `KIMI_API_KEY` (Pi has no env key) |
| `opencode-go` | `opencode-go` | Pi full list | Paseo hint `OPENCODE_API_KEY` |
Only `chatgpt` ships with a default model (`gpt-5.4-mini`). API-key providers currently
require at least one explicit `--model` or `options.models[]` entry.
OpenRouter intentionally has no default model because Pi's OpenRouter registry is large;
users must choose explicit model ids. The other catalog entries expose Pi's bundled model
list unless an instance sets `options.models`.
## Config shape
`agents.paseo.providers` is structurally generic. Provider instance names are free-form
keys; provider types are catalog ids. That means you can use the catalog id as the
default instance name, or create several instances of the same type with different
models, keys, or base URLs.
keys; provider types are catalog ids. Use the catalog id as the default instance name,
or create several instances of the same type with different models, keys, or endpoint
overrides.
```jsonc
{
@@ -65,9 +66,6 @@ models, keys, or base URLs.
},
"chatgpt": {
"type": "chatgpt",
"options": {
"models": [{ "id": "gpt-5.4-mini", "reasoning": true }],
},
},
"kimi": {
"type": "kimi",
@@ -82,42 +80,18 @@ models, keys, or base URLs.
}
```
The provider entry shape is:
Most options are overrides over Pi-derived provider data:
```jsonc
{
"type": "openrouter",
"options": {
"apiKey": "$OPENROUTER_API_KEY",
"baseUrl": "https://...",
"api": "openai-completions",
"headers": { "X-Example": "value" },
"authHeader": true,
"refreshToken": "$CHATGPT_REFRESH_TOKEN",
"models": [
{
"id": "provider/model-id",
"label": "Readable label",
"api": "anthropic-messages",
"reasoning": true,
"contextWindow": 128000,
"maxTokens": 16384,
},
],
},
}
```
Most options are overrides for the catalog entry:
- `apiKey` may be omitted for API-key providers. Omitted means "use the catalog env var"
such as `OPENROUTER_API_KEY`.
- `apiKey` may be omitted for API-key providers. Omitted means "use the derived or hinted
env var", such as `OPENROUTER_API_KEY` or `KIMI_API_KEY`.
- `apiKey` may also be a literal key, `$ENV`, `${ENV}`, or `!command` expression. Paseo
mirrors Pi's config-value semantics: literals and commands count as configured; env
references count only when every referenced env var is set in the daemon environment.
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend catalog defaults.
- `models[]` exposes the model ids usable as `<providerInstance>/<modelId>`. A model may
override `api` when a single backend serves mixed protocols.
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend the Pi-derived request
config.
- `models[]` is an instance override. Omit it to use that entry's default policy. A model
may override `api` when a single backend serves mixed protocols or when Pi has no data
for a custom id.
- `refreshToken` is an advanced OAuth seed path. Prefer the OAuth store described below.
Env references make config portable: `config.json` can be copied between machines while
@@ -169,58 +143,14 @@ The current catalog only uses `openai-codex` for `chatgpt`.
The provider CLI talks to the selected daemon. Always pass `--host` when smoking an
isolated daemon.
Actual help text:
Commands:
```text
Usage: paseo provider add [options] [id]
Configure a Paseo Agent model provider
Arguments:
id Catalog provider id; omit to choose interactively
Options:
--name <instanceName> Provider instance name (default: provider id)
--model <id> Model ID to expose (repeatable, comma-separated;
defaults to catalog models) (default: [])
--api-key-stdin Read API key from stdin
--device-code Use daemon-run device-code OAuth instead of browser
OAuth
--json Output in JSON format
--host <host> Daemon host target: host:port or
tcp://host:port?ssl=true&password=secret (default:
local socket/pipe, then localhost:6767)
-h, --help display help for command
```
```text
Usage: paseo provider ls [options]
List configured Paseo Agent model providers
Options:
--json Output in JSON format
--host <host> Daemon host target: host:port or
tcp://host:port?ssl=true&password=secret (default: local
socket/pipe, then localhost:6767)
-h, --help display help for command
```
```text
Usage: paseo provider rm [options] <name>
Remove a Paseo Agent model provider
Arguments:
name Provider instance name
Options:
--json Output in JSON format
--host <host> Daemon host target: host:port or
tcp://host:port?ssl=true&password=secret (default: local
socket/pipe, then localhost:6767)
-h, --help display help for command
```
- `paseo provider add [id]` configures a catalog provider. Omit `id` to choose from the
daemon catalog. Use `--name`, repeated/comma-separated `--model`, `--api-key-stdin`,
`--device-code`, `--json`, and `--host` as needed.
- `paseo provider ls` lists configured instances and redacted auth state.
- `paseo provider rm <name>` removes one provider instance and clears `defaultModel` if
it pointed at that instance.
Examples:
@@ -295,13 +225,11 @@ Adding a new model-provider type should be a data change:
1. Add one entry to `PASEO_AGENT_PROVIDER_CATALOG` in
`packages/server/src/server/agent/providers/paseo-agent/catalog.ts`.
2. Set `id`, `label`, `api`, `baseUrl`, `auth`, and any provider-level `headers`.
3. Add default `models[]` only when there is a safe, broadly usable default. Otherwise
leave it empty so CLI/app users must choose a model id.
4. Use `auth: { kind: "api_key", envVar: "..." }` for key-backed providers.
5. Use `auth: { kind: "oauth", flow: "..." }` only for flows supported by Pi's OAuth
registry (`openai-codex`, `anthropic`, `github-copilot`).
6. Add or update focused tests around catalog copying, provider resolution, auth state,
2. Set `id`, `piProvider`, `label`, and `defaultModels: false` only when the Pi provider
should not expose its full model list by default.
3. Add `auth` only when Pi cannot infer the auth source or when an explicit flow hint keeps
resolution simple.
4. Add or update focused tests around catalog assembly, provider resolution, auth state,
and CLI/app rendering if the new entry exercises a new shape.
Do not add provider-specific branches in the runtime, CLI, or app. The catalog entry is
@@ -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) } };
});
}