mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
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:
+37
-109
@@ -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">
|
||||
|
||||
@@ -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) } };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user