diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 7a82be788..667f32bf2 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -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 `/`. 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 Provider instance name (default: provider id) - --model 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 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 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] - -Remove a Paseo Agent model provider - -Arguments: - name Provider instance name - -Options: - --json Output in JSON format - --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 ` 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 diff --git a/packages/app/src/components/paseo-agent-settings-sheet.tsx b/packages/app/src/components/paseo-agent-settings-sheet.tsx index eeeb3e982..ae2c94716 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet.tsx +++ b/packages/app/src/components/paseo-agent-settings-sheet.tsx @@ -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; 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 ( + + {children} + + ); +} + function DynamicProviderIcon({ iconKey, size, color = "" }: DynamicProviderIconProps) { const Icon = getProviderIcon(iconKey); return ; @@ -335,23 +318,11 @@ function CatalogPickerSubSheet({ }) { let body: React.ReactNode; if (error) { - body = ( - - {error} - - ); + body = {error}; } else if (isLoading) { - body = ( - - Loading... - - ); + body = Loading...; } else if (catalog.length === 0) { - body = ( - - No providers available. - - ); + body = No providers available.; } else { body = ( @@ -460,10 +431,11 @@ function ProviderNameAndModelsFields({ resetKey, lockName, hasCatalogModels, - modelInputStyle, onNameChange, onModelsChange, }: ProviderNameAndModelsFieldsProps) { + const modelInputStyle = useMemo(() => [styles.formInput, styles.modelsInput], []); + return ( <> Provider name @@ -537,23 +509,6 @@ function ApiKeyFields({ auth, apiKey, resetKey, onApiKeyChange }: ApiKeyFieldsPr ); } -function OAuthFields({ oauthState, activeAuthorization }: OAuthFieldsProps) { - return ( - <> - {activeAuthorization ? ( - - ) : ( - Sign in to connect this provider. - )} - {oauthState.status === "error" ? ( - - {oauthState.message} - - ) : null} - - ); -} - function ProviderFormActions({ saving, canSubmit, @@ -659,8 +614,6 @@ function PaseoAgentProviderFormSheet({ const [oauthState, setOAuthState] = useState({ 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( () => ({ @@ -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 ? ( - + <> + {activeAuthorization ? ( + + ) : ( + Sign in to connect this provider. + )} + {oauthState.status === "error" ? ( + + {oauthState.message} + + ) : null} + ) : null} {!apiKeyAuth && !oauthAuth ? ( @@ -922,28 +883,16 @@ export function PaseoAgentSettingsSheet({ let body: React.ReactNode; if (!supported) { body = ( - - Update the host to configure Paseo Agent. - + + Update the host to configure Paseo Agent. + ); } else if (error) { - body = ( - - {error} - - ); + body = {error}; } else if (isLoading) { - body = ( - - Loading... - - ); + body = Loading...; } else if (providers.length === 0) { - body = ( - - No providers configured yet. - - ); + body = No providers configured yet.; } else { body = ( diff --git a/packages/cli/src/commands/provider/add.ts b/packages/cli/src/commands/provider/add.ts index 032bf88d9..2b891fd6a 100644 --- a/packages/cli/src/commands/provider/add.ts +++ b/packages/cli/src/commands/provider/add.ts @@ -139,31 +139,32 @@ function requirePaseoAgentCatalogFeature( } satisfies CommandError; } -function authField(auth: Record, 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 { 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 { diff --git a/packages/server/src/server/agent/providers/paseo-agent/catalog.ts b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts index 0ab02b4e3..ff09d31ad 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/catalog.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/catalog.ts @@ -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; - compat?: Record; - 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 = { "openai-codex": "chatgpt", @@ -82,7 +58,7 @@ const PASEO_AGENT_PROVIDER_ALIASES: Record = { 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)); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts index f67c3d513..d6f8ddcd1 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -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[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, }), diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index a899ead96..70d60a815 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -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; 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, @@ -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); diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts index 006646b70..11a482f20 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.test.ts @@ -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[0]): string[] { + return getModels(provider).map((model) => model.id); +} + function configWith(overrides?: Partial): 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", () => { diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index 9be61a27a..2254bacdf 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -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; export type PaseoAgentModelProviderEntry = z.infer; -type PiModelConfig = NonNullable[number]; +export type PaseoAgentProviderModelConfig = z.infer; +export type { PaseoAgentCatalogManifestEntry }; +type PiModel = Model; +type PiModelConfig = NonNullable[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; authHeader?: boolean; @@ -74,37 +96,131 @@ function entries(config: PaseoAgentConfig): [string, PaseoAgentModelProviderEntr return Object.entries(config.providers ?? {}); } -function mergeHeaders( - catalogHeaders: Record | undefined, - optionHeaders: Record | undefined, -): Record | 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) } }; }); }