fix(paseo-agent): support empty model-provider setup

Allow catalog provider setup to persist credentials before explicit model overrides exist, while keeping unrelated provider snapshots warmable after Paseo Agent config changes.
This commit is contained in:
Mohamed Boudra
2026-07-02 13:12:23 +02:00
parent d6e4e5f3ce
commit 5d67032ef6
17 changed files with 441 additions and 209 deletions

View File

@@ -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.

View File

@@ -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({

View File

@@ -102,7 +102,7 @@ export function createPaseoAgentProviderInput(input: {
name: input.name.trim(),
providerType: input.entry.id,
options: {
models,
...(models.length > 0 ? { models } : {}),
...(apiKey ? { apiKey } : {}),
},
};

View File

@@ -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<unknown>;
startOAuth?: (name: string) => Promise<unknown>;
completeOAuth?: (name: string) => Promise<unknown>;
@@ -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,

View File

@@ -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<string> {
}
}
function requirePaseoAgentCatalogFeature(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
): 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 <model-id>. 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<RedactedPaseoAgentProviderConfig> {
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) {

View File

@@ -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;
}

View File

@@ -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) => {

View File

@@ -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<ProviderListItem> = {
{ 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<ProviderListItem>;
@@ -47,18 +60,6 @@ export interface ProviderLsOptions extends CommandOptions {
host?: string;
}
function requirePaseoAgentCatalogFeature(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
): 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";

View File

@@ -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<ProviderRemoveItem> = {
],
};
function requirePaseoAgentCatalogFeature(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
): 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,

View File

@@ -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<ProviderModel[]>
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<ProviderListRow[]> {
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",

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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();

View File

@@ -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<void>((resolveStarted) => {
resolveFetchStarted = resolveStarted;
});
const fetchRelease = new Promise<void>((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(),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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;