diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 667f32bf2..845009ff4 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -36,9 +36,10 @@ The current catalog contains four entries: | `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` | -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`. +OpenRouter intentionally has no default model because Pi's OpenRouter registry is large. +Users can store the OpenRouter credential first, but they must choose explicit model ids +before running Paseo Agent through that provider. The other catalog entries expose Pi's +bundled model list unless an instance sets `options.models`. ## Config shape @@ -89,9 +90,9 @@ Most options are overrides over Pi-derived provider data: references count only when every referenced env var is set in the daemon environment. - `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. +- `models[]` is an instance override. Omit it to use that entry's default policy, which + can be an empty list for catalog entries such as OpenRouter. 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 @@ -108,11 +109,15 @@ explicit session model wins, then the selected agent definition's model, then API-key providers use the catalog auth metadata plus the configured `apiKey` expression. Redacted provider responses include an optional `auth` state: -- `Connected` means the key or credential is configured and at least one model is exposed. +- `Connected` means the key or credential expression resolves locally. It does not make a + network call, so a fake literal key still reports connected until a real session uses it. - `Needs attention` means the instance exists but the auth expression does not currently resolve, the OAuth store binding does not match, or another auth precondition is missing. - `not configured` is used by older/no-auth responses. +The redacted provider `available` flag mirrors local credential availability. The Paseo +Agent runtime still needs at least one exposed model before it can start a session. + Secrets are not returned in catalog responses, redacted provider responses, or CLI table output. diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts index e4a023ab4..63f59a22a 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.test.ts @@ -121,6 +121,22 @@ describe("paseo-agent-settings-sheet-model", () => { }); }); + it("omits model overrides when the catalog has no defaults", () => { + expect( + createPaseoAgentProviderInput({ + entry: catalogEntry({ models: [] }), + name: "alpha-main", + apiKey: "alpha-secret", + }), + ).toEqual({ + name: "alpha-main", + providerType: "catalog-alpha", + options: { + apiKey: "alpha-secret", + }, + }); + }); + it("builds a generic provider payload with an env reference for an empty key", () => { expect( createPaseoAgentProviderInput({ diff --git a/packages/app/src/components/paseo-agent-settings-sheet-model.ts b/packages/app/src/components/paseo-agent-settings-sheet-model.ts index 15a1630eb..4dadf2541 100644 --- a/packages/app/src/components/paseo-agent-settings-sheet-model.ts +++ b/packages/app/src/components/paseo-agent-settings-sheet-model.ts @@ -102,7 +102,7 @@ export function createPaseoAgentProviderInput(input: { name: input.name.trim(), providerType: input.entry.id, options: { - models, + ...(models.length > 0 ? { models } : {}), ...(apiKey ? { apiKey } : {}), }, }; diff --git a/packages/cli/src/commands/provider/add.test.ts b/packages/cli/src/commands/provider/add.test.ts index 82b30ad6a..5d88439f0 100644 --- a/packages/cli/src/commands/provider/add.test.ts +++ b/packages/cli/src/commands/provider/add.test.ts @@ -18,7 +18,7 @@ interface RecordingClientInput { setProvider?: (input: { name: string; providerType: string; - options: { apiKey?: string; models: Array<{ id: string }> }; + options: { apiKey?: string; models?: Array<{ id: string }> }; }) => Promise; startOAuth?: (name: string) => Promise; completeOAuth?: (name: string) => Promise; @@ -40,7 +40,7 @@ function createClient(input: RecordingClientInput) { setPaseoAgentProvider: async (providerInput: { name: string; providerType: string; - options: { apiKey?: string; models: Array<{ id: string }> }; + options: { apiKey?: string; models?: Array<{ id: string }> }; }) => { if (input.setProvider) { return input.setProvider(providerInput); @@ -51,7 +51,7 @@ function createClient(input: RecordingClientInput) { provider: { name: providerInput.name, providerType: providerInput.providerType, - models: providerInput.options.models, + models: providerInput.options.models ?? [], auth: { kind: "api_key", configured: true, source: "literal" }, available: true, error: null, @@ -159,7 +159,7 @@ describe("provider add", () => { provider: { name: input.name, providerType: input.providerType, - models: input.options.models, + models: input.options.models ?? [], auth: { kind: "api_key", configured: true, source: "literal" }, available: true, error: null, @@ -211,7 +211,7 @@ describe("provider add", () => { provider: { name: input.name, providerType: input.providerType, - models: input.options.models, + models: input.options.models ?? [], auth: { kind: "api_key", configured: false, source: "env" }, available: false, error: null, @@ -258,7 +258,7 @@ describe("provider add", () => { provider: { name: input.name, providerType: input.providerType, - models: input.options.models, + models: input.options.models ?? [], auth: { kind: "api_key", configured: true, source: "literal" }, available: true, error: null, @@ -281,6 +281,57 @@ describe("provider add", () => { ]); }); + it("configures an API-key provider without model defaults", async () => { + const setCalls: unknown[] = []; + + const result = await runAddCommand("alpha-key", { apiKeyStdin: true }, {} as never, { + readStdin: async () => "stdin-secret\n", + promptSecret: async () => { + throw new Error("prompt should not be used with --api-key-stdin"); + }, + promptText: async () => { + throw new Error("text prompt should not be used"); + }, + write: () => {}, + connectDaemon: async () => + createClient({ + catalog: [apiKeyEntry({ models: [] })], + setProvider: async (input) => { + setCalls.push(input); + return { + requestId: "set-1", + success: true, + provider: { + name: input.name, + providerType: input.providerType, + models: [], + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + error: null, + }; + }, + }), + }); + + expect(setCalls).toEqual([ + { + name: "alpha-key", + providerType: "alpha-key", + options: { + apiKey: "stdin-secret", + }, + }, + ]); + expect(result.data).toMatchObject({ + name: "alpha-key", + auth: "Connected", + available: "yes", + models: "-", + }); + }); + it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => { const order: string[] = []; const stored: unknown[] = []; @@ -322,7 +373,7 @@ describe("provider add", () => { provider: { name: input.name, providerType: input.providerType, - models: input.options.models, + models: input.options.models ?? [], auth: { kind: "oauth", configured: false }, available: false, error: null, @@ -493,7 +544,7 @@ describe("provider add", () => { provider: { name: input.name, providerType: input.providerType, - models: input.options.models, + models: input.options.models ?? [], auth: { kind: "api_key", configured: true, source: "literal" }, available: true, error: null, @@ -524,6 +575,34 @@ describe("provider add", () => { ]); }); + it("mentions known provider ids for an unknown catalog id", async () => { + await expect( + runAddCommand("missing-key", {}, {} as never, { + promptSecret: async () => { + throw new Error("prompt should not run"); + }, + promptText: async () => { + throw new Error("text prompt should not run"); + }, + readStdin: async () => { + throw new Error("stdin should not run"); + }, + write: () => {}, + connectDaemon: async () => + createClient({ + catalog: [apiKeyEntry({ id: "alpha-key" }), apiKeyEntry({ id: "gamma-key" })], + setProvider: async () => { + throw new Error("set should not run"); + }, + }), + }), + ).rejects.toMatchObject({ + code: "UNKNOWN_PROVIDER", + message: + 'Unknown model provider type "missing-key". Known provider ids: alpha-key, gamma-key.', + }); + }); + it("requires the catalog feature flag before reading the catalog", async () => { const calls: string[] = []; @@ -601,7 +680,7 @@ describe("provider add", () => { provider: { name: input.name, providerType: input.providerType, - models: input.options.models, + models: input.options.models ?? [], auth: { kind: "api_key", configured: true, source: "literal" }, available: true, error: null, diff --git a/packages/cli/src/commands/provider/add.ts b/packages/cli/src/commands/provider/add.ts index 2b891fd6a..8b23c34d7 100644 --- a/packages/cli/src/commands/provider/add.ts +++ b/packages/cli/src/commands/provider/add.ts @@ -13,6 +13,7 @@ import { loginOAuthBrowser } from "@getpaseo/server"; import { connectToDaemon } from "../../utils/client.js"; import { collectMultiple } from "../../utils/command-options.js"; import { openBrowserUrl } from "../../utils/open-browser.js"; +import { requirePaseoAgentCatalogFeature } from "./feature.js"; import type { CommandError, CommandOptions, @@ -127,18 +128,6 @@ async function promptSecret(message: string): Promise { } } -function requirePaseoAgentCatalogFeature( - client: Pick, -): void { - if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) { - return; - } - throw { - code: "HOST_UPDATE_REQUIRED", - message: "Update the Paseo daemon to use this command.", - } satisfies CommandError; -} - function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined { const auth = entry.auth; const value = auth[field]; @@ -185,10 +174,10 @@ function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] { })); } -function requireModels( +function resolveModels( entry: PaseoAgentCatalogEntry, options: ProviderAddOptions, -): ProviderModelInput[] { +): ProviderModelInput[] | undefined { const modelIds = normalizeModels(options.model); if (modelIds.length > 0) { return modelIds.map((id) => ({ id })); @@ -198,12 +187,7 @@ function requireModels( if (models.length > 0) { return models; } - throw { - code: "MISSING_MODELS", - message: `At least one model is required for ${entry.label}.`, - details: - "Pass --model . Repeat --model to configure more than one; comma-separated values are also accepted.", - } satisfies CommandError; + return undefined; } async function selectCatalogEntry( @@ -249,9 +233,10 @@ async function resolveEntry( return entry; } + const knownIds = catalog.map((candidate) => candidate.id).join(", "); throw { code: "UNKNOWN_PROVIDER", - message: `Unknown model provider type "${id}".`, + message: `Unknown model provider type "${id}". Known provider ids: ${knownIds}.`, } satisfies CommandError; } @@ -427,7 +412,7 @@ async function configureProvider( options: ProviderAddOptions, dependencies: ProviderAddDependencies, ): Promise { - const models = requireModels(entry, options); + const models = resolveModels(entry, options); const apiKey = entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined; const result = await client.setPaseoAgentProvider({ @@ -435,7 +420,7 @@ async function configureProvider( providerType: entry.id, options: { ...(apiKey ? { apiKey } : {}), - models, + ...(models ? { models } : {}), }, }); if (!result.success || !result.provider) { diff --git a/packages/cli/src/commands/provider/feature.ts b/packages/cli/src/commands/provider/feature.ts new file mode 100644 index 000000000..718389023 --- /dev/null +++ b/packages/cli/src/commands/provider/feature.ts @@ -0,0 +1,17 @@ +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { CommandError } from "../../output/index.js"; + +export interface PaseoAgentCatalogFeatureClient extends Pick< + DaemonClient, + "getLastServerInfoMessage" +> {} + +export function requirePaseoAgentCatalogFeature(client: PaseoAgentCatalogFeatureClient): void { + if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) { + return; + } + throw { + code: "HOST_UPDATE_REQUIRED", + message: "Update the Paseo daemon to use this command.", + } satisfies CommandError; +} diff --git a/packages/cli/src/commands/provider/ls-rm.test.ts b/packages/cli/src/commands/provider/ls-rm.test.ts index 0d4c51352..cec69aa65 100644 --- a/packages/cli/src/commands/provider/ls-rm.test.ts +++ b/packages/cli/src/commands/provider/ls-rm.test.ts @@ -13,6 +13,30 @@ function createServerInfo() { } describe("provider ls", () => { + it("renders an empty configured-provider table with headers", async () => { + const result = await runLsCommand({ host: "localhost:7777" }, {} as never, { + connectDaemon: async () => ({ + getLastServerInfoMessage: createServerInfo, + getPaseoAgentCatalog: async () => ({ + requestId: "catalog-1", + catalog: [], + error: null, + }), + getPaseoAgentProviders: async () => ({ + requestId: "providers-1", + defaultModel: null, + providers: [], + error: null, + }), + close: async () => {}, + }), + }); + + expect(result.data).toEqual([]); + expect(render(result, { format: "table", noColor: true })).toContain("NAME"); + expect(render(result, { format: "json" })).toBe("[]"); + }); + it("lists configured model providers with catalog labels and auth states", async () => { const result = await runLsCommand({ host: "localhost:7777" }, {} as never, { connectDaemon: async (options) => { diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 35532d894..f28761fbc 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -1,11 +1,18 @@ import type { Command } from "commander"; -import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; +import { + renderTable, + renderTableHeader, + type CommandOptions, + type ListResult, + type OutputSchema, +} from "../../output/index.js"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { PaseoAgentCatalogEntry, RedactedPaseoAgentProviderConfig, } from "@getpaseo/protocol/messages"; import { connectToDaemon } from "../../utils/client.js"; +import { requirePaseoAgentCatalogFeature } from "./feature.js"; export interface ProviderListItem { name: string; @@ -39,6 +46,12 @@ export const providerLsSchema: OutputSchema = { { header: "AVAILABLE", field: "available", width: 10 }, { header: "MODELS", field: "models", width: 30 }, ], + renderHuman: (result, options) => { + if (result.type === "list" && result.data.length === 0) { + return options.noHeaders ? "" : renderTableHeader(providerLsSchema, options); + } + return renderTable(result, options); + }, }; export type ProviderLsResult = ListResult; @@ -47,18 +60,6 @@ export interface ProviderLsOptions extends CommandOptions { host?: string; } -function requirePaseoAgentCatalogFeature( - client: Pick, -): void { - if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) { - return; - } - throw { - code: "HOST_UPDATE_REQUIRED", - message: "Update the Paseo daemon to use this command.", - }; -} - function authState(provider: RedactedPaseoAgentProviderConfig): string { if (!provider.auth) { return "not configured"; diff --git a/packages/cli/src/commands/provider/rm.ts b/packages/cli/src/commands/provider/rm.ts index e47af4e2d..def69985e 100644 --- a/packages/cli/src/commands/provider/rm.ts +++ b/packages/cli/src/commands/provider/rm.ts @@ -8,6 +8,7 @@ import type { OutputSchema, SingleResult, } from "../../output/index.js"; +import { requirePaseoAgentCatalogFeature } from "./feature.js"; interface ProviderRmOptions extends CommandOptions { host?: string; @@ -39,18 +40,6 @@ export const providerRemoveSchema: OutputSchema = { ], }; -function requirePaseoAgentCatalogFeature( - client: Pick, -): void { - if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) { - return; - } - throw { - code: "HOST_UPDATE_REQUIRED", - message: "Update the Paseo daemon to use this command.", - } satisfies CommandError; -} - export async function runRmCommand( name: string, options: ProviderRmOptions, diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index f194ff1d4..fe244875c 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -3,15 +3,18 @@ /** * Phase 15: Provider Command Tests * - * Tests provider commands for listing providers and models. - * Provider ls data is static, while provider models are fetched via daemon integration. - * This test uses an isolated daemon to avoid coupling to a user's long-running daemon. + * Tests provider commands for configured Paseo Agent model providers and agent + * provider model listing. This test uses an isolated daemon to avoid coupling to + * a user's long-running daemon. * * Tests: * - provider --help shows subcommands - * - provider ls lists all providers - * - provider ls --json outputs valid JSON - * - provider ls --quiet outputs provider names only + * - provider ls on a fresh daemon prints an empty configured-provider table + * - provider add stores an API-key model provider without network validation + * - repeated provider add updates the same provider instance + * - provider add rejects unknown catalog ids with known ids + * - provider rm removes a configured model provider + * - provider add uses catalog default models when present * - provider models claude lists claude models * - provider models codex lists codex models * - provider models opencode lists opencode models @@ -20,14 +23,7 @@ */ import assert from "node:assert"; -import { writeFile } from "node:fs/promises"; -import { join } from "node:path"; -import { - createE2ETestContext, - createTempDirs, - runPaseoCli, - startTestDaemon, -} from "./helpers/test-daemon.ts"; +import { createE2ETestContext } from "./helpers/test-daemon.ts"; console.log("=== Provider Commands ===\n"); @@ -38,10 +34,12 @@ interface ProviderModel { } interface ProviderListRow { - provider: string; + name: string; + providerType: string; label: string; - status: string; - enabled: string; + auth: string; + available: string; + models: string; } const EXPECTED_CLAUDE_MODELS = [ @@ -131,6 +129,24 @@ async function runProviderModelsJson(provider: string): Promise return attemptRun(1); } +function parseProviderListJson(stdout: string): ProviderListRow[] { + const data = JSON.parse(stdout.trim()) as ProviderListRow[]; + assert(Array.isArray(data), "provider ls --json output should be an array"); + return data; +} + +async function getProviderRows(): Promise { + const result = await ctx.paseo(["provider", "ls", "--json"]); + assert.strictEqual(result.exitCode, 0, "provider ls --json should exit 0"); + return parseProviderListJson(result.stdout); +} + +function assertProviderTableHeader(stdout: string): void { + for (const header of ["NAME", "TYPE", "LABEL", "AUTH", "AVAILABLE", "MODELS"]) { + assert(stdout.includes(header), `provider ls table should include ${header}`); + } +} + function assertClaudeModels(data: ProviderModel[]): void { assert.strictEqual( data.length, @@ -167,142 +183,135 @@ try { const result = await ctx.paseo(["provider", "--help"]); assert.strictEqual(result.exitCode, 0, "provider --help should exit 0"); assert(result.stdout.includes("ls"), "help should mention ls"); + assert(result.stdout.includes("add"), "help should mention add"); + assert(result.stdout.includes("rm"), "help should mention rm"); assert(result.stdout.includes("models"), "help should mention models"); console.log("✓ provider --help shows subcommands\n"); } - // Test 2: provider ls lists all providers + // Test 2: provider ls on a fresh daemon shows no configured providers { - console.log("Test 2: provider ls lists all providers"); + console.log("Test 2: provider ls on a fresh daemon shows no configured providers"); const result = await ctx.paseo(["provider", "ls"]); assert.strictEqual(result.exitCode, 0, "provider ls should exit 0"); - assert(result.stdout.includes("claude"), "output should include claude"); - assert(result.stdout.includes("codex"), "output should include codex"); - assert(result.stdout.includes("opencode"), "output should include opencode"); - assert(result.stdout.includes("ENABLED"), "output should include ENABLED column"); - assert(result.stdout.includes("Enabled"), "output should show enabled providers"); - assert( - result.stdout.includes("available") || - result.stdout.includes("loading") || - result.stdout.includes("unavailable"), - "output should show a provider status", - ); - console.log("✓ provider ls lists all providers\n"); + assertProviderTableHeader(result.stdout); + assert(!result.stdout.includes("OpenRouter"), "fresh output should have no provider rows"); + + const jsonResult = await ctx.paseo(["provider", "ls", "--json"]); + assert.strictEqual(jsonResult.exitCode, 0, "provider ls --json should exit 0"); + assert.deepStrictEqual(parseProviderListJson(jsonResult.stdout), []); + console.log("✓ provider ls on a fresh daemon shows no configured providers\n"); } - // Test 3: provider ls --json outputs valid JSON + // Test 3: provider add openrouter stores a dummy key without network validation { - console.log("Test 3: provider ls --json outputs valid JSON"); - const result = await ctx.paseo(["provider", "ls", "--json"]); - assert.strictEqual(result.exitCode, 0, "should exit 0"); - const data = JSON.parse(result.stdout.trim()); - assert(Array.isArray(data), "output should be an array"); - assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`); - assert( - data.some((p: { provider: string }) => p.provider === "claude"), - "should include claude", - ); - assert( - data.some((p: { provider: string }) => p.provider === "codex"), - "should include codex", - ); - assert( - data.some((p: { provider: string }) => p.provider === "opencode"), - "should include opencode", - ); - const rows = data as ProviderListRow[]; - for (const provider of ["claude", "codex", "opencode"] as const) { - const row = rows.find((p) => p.provider === provider); - assert(row, `should include ${provider}`); - assert.strictEqual(row.enabled, "Enabled", `${provider} should report Enabled`); - } + console.log("Test 3: provider add openrouter stores a dummy key without network validation"); + const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], { + stdin: "dummy-openrouter-key\n", + }); + assert.strictEqual(result.exitCode, 0, `provider add should exit 0\n${result.stderr}`); + assert(result.stdout.includes("openrouter"), "add output should include the instance name"); + assert(result.stdout.includes("OpenRouter"), "add output should include the catalog label"); + assert(result.stdout.includes("Connected"), "add output should show connected auth state"); + assert(result.stdout.includes("yes"), "add output should show the provider as available"); - const omp = rows.find((p) => p.provider === "omp"); - assert(omp, "should include omp"); - assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default"); - console.log("✓ provider ls --json outputs valid JSON\n"); + const rows = await getProviderRows(); + assert.strictEqual(rows.length, 1, "provider ls should show exactly one configured instance"); + assert.deepStrictEqual(rows[0], { + name: "openrouter", + providerType: "openrouter", + label: "OpenRouter", + auth: "Connected", + available: "yes", + models: "-", + }); + console.log("✓ provider add openrouter stores a dummy key without network validation\n"); } - // Test 4: provider ls includes disabled providers + // Test 4: provider add is idempotent for the same instance name { - console.log("Test 4: provider ls includes disabled providers"); - const { paseoHome, workDir } = await createTempDirs(); - await writeFile( - join(paseoHome, "config.json"), - JSON.stringify( - { - version: 1, - agents: { - providers: { - claude: { - enabled: false, - }, - }, - }, - }, - null, - 2, - ) + "\n", + console.log("Test 4: provider add is idempotent for the same instance name"); + const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], { + stdin: "dummy-openrouter-key-2\n", + }); + assert.strictEqual(result.exitCode, 0, `provider add should exit 0\n${result.stderr}`); + + const rows = await getProviderRows(); + assert.strictEqual(rows.length, 1, "re-running add should not create another instance"); + assert.strictEqual(rows[0]?.name, "openrouter"); + assert.strictEqual(rows[0]?.label, "OpenRouter"); + console.log("✓ provider add is idempotent for the same instance name\n"); + } + + // Test 5: provider add rejects unknown catalog ids with known ids + { + console.log("Test 5: provider add rejects unknown catalog ids with known ids"); + const result = await ctx.paseo(["provider", "add", "nonsense-id", "--api-key-stdin"], { + stdin: "dummy-key\n", + }); + assert.notStrictEqual(result.exitCode, 0, "provider add should fail for unknown ids"); + const output = result.stdout + result.stderr; + assert(output.includes("nonsense-id"), "error should mention the requested id"); + assert(output.includes("Known provider ids"), "error should mention known provider ids"); + assert(output.includes("openrouter"), "known ids should include openrouter"); + assert(output.includes("kimi"), "known ids should include kimi"); + console.log("✓ provider add rejects unknown catalog ids with known ids\n"); + } + + // Test 6: provider rm removes a configured provider + { + console.log("Test 6: provider rm removes a configured provider"); + const result = await ctx.paseo(["provider", "rm", "openrouter"]); + assert.strictEqual(result.exitCode, 0, `provider rm should exit 0\n${result.stderr}`); + assert(result.stdout.includes("openrouter"), "rm output should include the instance name"); + assert(result.stdout.includes("yes"), "rm output should report removal"); + + const rows = await getProviderRows(); + assert.deepStrictEqual(rows, [], "provider ls should be empty after removing openrouter"); + const table = await ctx.paseo(["provider", "ls"]); + assert.strictEqual(table.exitCode, 0, "provider ls should stay successful after removal"); + assertProviderTableHeader(table.stdout); + console.log("✓ provider rm removes a configured provider\n"); + } + + // Test 7: provider add uses catalog default models when present + { + console.log("Test 7: provider add uses catalog default models when present"); + const result = await ctx.paseo(["provider", "add", "kimi", "--api-key-stdin"], { + stdin: "dummy-kimi-key\n", + }); + assert.strictEqual(result.exitCode, 0, `provider add kimi should exit 0\n${result.stderr}`); + assert(result.stdout.includes("kimi"), "add output should include the instance name"); + assert(result.stdout.includes("Kimi Coding Plan"), "add output should include the label"); + + const rows = await getProviderRows(); + const kimi = rows.find((row) => row.name === "kimi"); + assert(kimi, "provider ls should include the kimi instance"); + assert.strictEqual(kimi.label, "Kimi Coding Plan"); + assert.strictEqual(kimi.auth, "Connected"); + assert.strictEqual(kimi.available, "yes"); + assert.notStrictEqual(kimi.models, "-", "kimi should expose catalog-derived default models"); + assert( + kimi.models + .split(",") + .map((model) => model.trim()) + .filter(Boolean).length > 0, + "kimi should list at least one catalog-derived model id", ); - - const disabledCtx = await startTestDaemon({ paseoHome, workDir, timeout: 120000 }); - try { - const result = await runPaseoCli(disabledCtx, ["provider", "ls", "--json"]); - assert.strictEqual(result.exitCode, 0, "provider ls should exit 0"); - const data = JSON.parse(result.stdout.trim()) as ProviderListRow[]; - const claude = data.find((p) => p.provider === "claude"); - assert(claude, "disabled claude provider should stay in provider ls"); - assert.strictEqual(claude.enabled, "Disabled", "disabled provider should report Disabled"); - - const opencode = data.find((p) => p.provider === "opencode"); - assert(opencode, "enabled opencode provider should stay in provider ls"); - assert.strictEqual(opencode.enabled, "Enabled", "enabled provider should report Enabled"); - - const modelsResult = await runPaseoCli(disabledCtx, ["provider", "models", "claude"]); - assert.notStrictEqual( - modelsResult.exitCode, - 0, - "provider models should fail for disabled providers", - ); - const output = modelsResult.stdout + modelsResult.stderr; - assert( - output.includes("Provider claude is disabled"), - "provider models should surface the daemon disabled error", - ); - assert( - !output.includes("claude-sonnet"), - "provider models should not print fallback models for disabled providers", - ); - } finally { - await disabledCtx.stop(); - } - console.log("✓ provider ls includes disabled providers\n"); + console.log("✓ provider add uses catalog default models when present\n"); } - // Test 5: provider ls --quiet outputs provider names only + // Test 8: provider models claude lists canonical model aliases { - console.log("Test 5: provider ls --quiet outputs provider names only"); - const result = await ctx.paseo(["provider", "ls", "--quiet"]); - assert.strictEqual(result.exitCode, 0, "should exit 0"); - const lines = result.stdout.trim().split("\n"); - assert(lines.length >= 3, `should have at least 3 lines, got ${lines.length}`); - assert(lines.includes("claude"), "should include claude"); - assert(lines.includes("codex"), "should include codex"); - assert(lines.includes("opencode"), "should include opencode"); - console.log("✓ provider ls --quiet outputs provider names only\n"); - } - - // Test 6: provider models claude lists canonical model aliases - { - console.log("Test 6: provider models claude lists canonical model aliases"); + console.log("Test 8: provider models claude lists canonical model aliases"); const data = await runProviderModelsJson("claude"); assertClaudeModels(data); console.log("✓ provider models claude lists canonical model aliases\n"); } - // Test 7: provider models codex includes concrete codex model IDs + // Test 9: provider models codex includes concrete codex model IDs { - console.log("Test 7: provider models codex includes concrete codex model IDs"); + console.log("Test 9: provider models codex includes concrete codex model IDs"); const data = await runProviderModelsJson("codex"); assert(data.length >= 1, "codex model list should not be empty"); const ids = data.map((m) => m.id); @@ -322,9 +331,9 @@ try { console.log("✓ provider models codex includes concrete codex model IDs\n"); } - // Test 8: provider models opencode returns namespaced model IDs + // Test 10: provider models opencode returns namespaced model IDs { - console.log("Test 8: provider models opencode returns namespaced model IDs"); + console.log("Test 10: provider models opencode returns namespaced model IDs"); const data = await runProviderModelsJson("opencode"); assert(data.length >= 1, "opencode model list should not be empty"); const ids = data.map((m) => m.id); @@ -343,9 +352,9 @@ try { console.log("✓ provider models opencode returns namespaced model IDs\n"); } - // Test 9: provider models unknown fails with error + // Test 11: provider models unknown fails with error { - console.log("Test 9: provider models unknown fails with error"); + console.log("Test 11: provider models unknown fails with error"); const result = await ctx.paseo(["provider", "models", "unknown"]); assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider"); const output = result.stdout + result.stderr; @@ -356,9 +365,9 @@ try { console.log("✓ provider models unknown fails with error\n"); } - // Test 10: provider models --json outputs valid JSON + // Test 12: provider models --json outputs valid JSON { - console.log("Test 10: provider models --json outputs valid JSON"); + console.log("Test 12: provider models --json outputs valid JSON"); const data = await runProviderModelsJson("claude"); assert(Array.isArray(data), "output should be an array"); assert( @@ -371,9 +380,9 @@ try { console.log("✓ provider models --json outputs valid JSON\n"); } - // Test 11: provider models --quiet outputs model IDs only + // Test 13: provider models --quiet outputs model IDs only { - console.log("Test 11: provider models --quiet outputs model IDs only"); + console.log("Test 13: provider models --quiet outputs model IDs only"); assert( claudeModelIdsFromJson.length > 0, "claude model IDs should be captured from --json output", diff --git a/packages/cli/tests/helpers/test-daemon.ts b/packages/cli/tests/helpers/test-daemon.ts index 95bbe3791..e09893d07 100644 --- a/packages/cli/tests/helpers/test-daemon.ts +++ b/packages/cli/tests/helpers/test-daemon.ts @@ -336,6 +336,7 @@ export async function runPaseoCli( timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; + stdin?: string; }, ): Promise<{ exitCode: number; stdout: string; stderr: string }> { const timeout = options?.timeout ?? 60000; @@ -354,7 +355,7 @@ export async function runPaseoCli( ...options?.env, }, cwd, - stdio: ["ignore", "pipe", "pipe"], + stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"], detached: process.platform !== "win32", }); @@ -369,6 +370,10 @@ export async function runPaseoCli( appendOutputCapture(stderr, data); }); + if (options?.stdin !== undefined) { + proc.stdin?.end(options.stdin); + } + const timeoutId = setTimeout(() => { if (proc.pid) { signalProcessTree(proc.pid, "SIGKILL"); @@ -406,7 +411,7 @@ export async function createE2ETestContext(options?: { /** Run a paseo CLI command against this daemon */ paseo: ( args: string[], - opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }, + opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string }, ) => Promise<{ exitCode: number; stdout: string; @@ -418,7 +423,7 @@ export async function createE2ETestContext(options?: { const paseo = ( args: string[], - opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv }, + opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string }, ) => runPaseoCli(ctx, args, opts); return { diff --git a/packages/protocol/src/messages.paseo-agent-config.test.ts b/packages/protocol/src/messages.paseo-agent-config.test.ts index 9c8e4f310..64d290627 100644 --- a/packages/protocol/src/messages.paseo-agent-config.test.ts +++ b/packages/protocol/src/messages.paseo-agent-config.test.ts @@ -19,6 +19,20 @@ describe("Paseo Agent config RPC schemas", () => { expect(parsed.providerType).toBe("openrouter"); }); + test("parses provider config requests without model overrides", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "config.paseo_agent.set_provider.request", + requestId: "req-set-openrouter", + name: "openrouter", + providerType: "openrouter", + options: { + apiKey: "sk-test", + }, + }); + + expect(parsed.options.models).toBeUndefined(); + }); + test("parses a provider type this client has never heard of (new daemon, old client)", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "config.paseo_agent.get_providers.response", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 9b75937ea..d894fb3a4 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1946,7 +1946,7 @@ const PaseoAgentSetProviderOptionsSchema = z api: z.string().min(1).optional(), headers: z.record(z.string(), z.string()).optional(), authHeader: z.boolean().optional(), - models: z.array(PaseoAgentProviderModelConfigSchema).min(1), + models: z.array(PaseoAgentProviderModelConfigSchema).min(1).optional(), }) .strict(); diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 28881c1a2..eaafe8d33 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1020,6 +1020,65 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => { }); describe("ProviderSnapshotManager applyPaseoAgentConfig", () => { + test("keeps unrelated provider loading state when Paseo Agent config changes", async () => { + let resolveFetchStarted: (() => void) | undefined; + let releaseFetch: (() => void) | undefined; + const fetchStarted = new Promise((resolveStarted) => { + resolveFetchStarted = resolveStarted; + }); + const fetchRelease = new Promise((resolveRelease) => { + releaseFetch = resolveRelease; + }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + codex: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }, + extraClients: { + claude: createExtraClient("claude", { + async isAvailable() { + return true; + }, + async fetchCatalog() { + resolveFetchStarted?.(); + await fetchRelease; + return { models: [] as AgentModelDefinition[], modes: [] as AgentMode[] }; + }, + }), + }, + paseoAgentConfig: {}, + }); + try { + manager.getSnapshot(); + await fetchStarted; + + manager.applyPaseoAgentConfig({ + providers: { + "openrouter-main": { + type: "openrouter", + options: { + apiKey: "sk-test", + models: [{ id: "anthropic/claude-3.7-sonnet", label: "Claude" }], + }, + }, + }, + }); + + expect(manager.getSnapshot().find((entry) => entry.provider === "claude")).toMatchObject({ + provider: "claude", + status: "loading", + enabled: true, + }); + } finally { + releaseFetch?.(); + manager.destroy(); + } + }); + test("refreshes Paseo Agent models without daemon restart", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index b5e689559..fccf4707c 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -585,7 +585,7 @@ export class ProviderSnapshotManager { defaultModeId: definition?.defaultModeId ?? null, }; - if (!definition?.enabled || !current || current.status === "loading") { + if (!definition?.enabled) { entries.set(provider, { ...metadata, status: "unavailable", @@ -594,6 +594,14 @@ export class ProviderSnapshotManager { continue; } + if (!current || current.status === "loading") { + entries.set(provider, { + ...metadata, + status: "loading", + }); + continue; + } + entries.set(provider, { ...current, ...metadata, 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 d6f8ddcd1..c2510295b 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 @@ -110,6 +110,27 @@ describe("PaseoAgentConfigService", () => { expect(JSON.stringify(presentService.getProviders())).not.toContain("sk-env-secret"); }); + test("reports configured API-key providers as available even without exposed models", () => { + const service = new PaseoAgentConfigService({ + paseoHome: home, + logger: createTestLogger(), + }); + + service.setProvider({ + name: "openrouter", + providerType: "openrouter", + options: { + apiKey: "sk-test", + }, + }); + + expect(service.getProviders().providers[0]).toMatchObject({ + auth: { kind: "api_key", configured: true, source: "literal" }, + models: [], + available: true, + }); + }); + test("rejects an unknown provider type with a clear error and persists nothing", () => { const service = new PaseoAgentConfigService({ paseoHome: home, 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 70d60a815..e991b886c 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 @@ -165,9 +165,9 @@ function redactedProviders( const provider: RedactedPaseoAgentProviderConfig = { name, providerType: catalogEntry.id, - models: models.map((model) => ({ ...model })), + models: models.map((model) => Object.assign({}, model)), auth, - available: auth.configured && models.length > 0, + available: auth.configured, error: null, }; provider.baseUrl = settings.baseUrl;