mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -102,7 +102,7 @@ export function createPaseoAgentProviderInput(input: {
|
||||
name: input.name.trim(),
|
||||
providerType: input.entry.id,
|
||||
options: {
|
||||
models,
|
||||
...(models.length > 0 ? { models } : {}),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
17
packages/cli/src/commands/provider/feature.ts
Normal file
17
packages/cli/src/commands/provider/feature.ts
Normal 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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user