mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +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) |
|
| `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` |
|
| `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;
|
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
|
Users can store the OpenRouter credential first, but they must choose explicit model ids
|
||||||
list unless an instance sets `options.models`.
|
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
|
## 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.
|
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
|
- `baseUrl`, `api`, `headers`, and `authHeader` override or extend the Pi-derived request
|
||||||
config.
|
config.
|
||||||
- `models[]` is an instance override. Omit it to use that entry's default policy. A model
|
- `models[]` is an instance override. Omit it to use that entry's default policy, which
|
||||||
may override `api` when a single backend serves mixed protocols or when Pi has no data
|
can be an empty list for catalog entries such as OpenRouter. A model may override `api`
|
||||||
for a custom id.
|
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.
|
- `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
|
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.
|
API-key providers use the catalog auth metadata plus the configured `apiKey` expression.
|
||||||
Redacted provider responses include an optional `auth` state:
|
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
|
- `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.
|
resolve, the OAuth store binding does not match, or another auth precondition is missing.
|
||||||
- `not configured` is used by older/no-auth responses.
|
- `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
|
Secrets are not returned in catalog responses, redacted provider responses, or CLI table
|
||||||
output.
|
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", () => {
|
it("builds a generic provider payload with an env reference for an empty key", () => {
|
||||||
expect(
|
expect(
|
||||||
createPaseoAgentProviderInput({
|
createPaseoAgentProviderInput({
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function createPaseoAgentProviderInput(input: {
|
|||||||
name: input.name.trim(),
|
name: input.name.trim(),
|
||||||
providerType: input.entry.id,
|
providerType: input.entry.id,
|
||||||
options: {
|
options: {
|
||||||
models,
|
...(models.length > 0 ? { models } : {}),
|
||||||
...(apiKey ? { apiKey } : {}),
|
...(apiKey ? { apiKey } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface RecordingClientInput {
|
|||||||
setProvider?: (input: {
|
setProvider?: (input: {
|
||||||
name: string;
|
name: string;
|
||||||
providerType: string;
|
providerType: string;
|
||||||
options: { apiKey?: string; models: Array<{ id: string }> };
|
options: { apiKey?: string; models?: Array<{ id: string }> };
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
startOAuth?: (name: string) => Promise<unknown>;
|
startOAuth?: (name: string) => Promise<unknown>;
|
||||||
completeOAuth?: (name: string) => Promise<unknown>;
|
completeOAuth?: (name: string) => Promise<unknown>;
|
||||||
@@ -40,7 +40,7 @@ function createClient(input: RecordingClientInput) {
|
|||||||
setPaseoAgentProvider: async (providerInput: {
|
setPaseoAgentProvider: async (providerInput: {
|
||||||
name: string;
|
name: string;
|
||||||
providerType: string;
|
providerType: string;
|
||||||
options: { apiKey?: string; models: Array<{ id: string }> };
|
options: { apiKey?: string; models?: Array<{ id: string }> };
|
||||||
}) => {
|
}) => {
|
||||||
if (input.setProvider) {
|
if (input.setProvider) {
|
||||||
return input.setProvider(providerInput);
|
return input.setProvider(providerInput);
|
||||||
@@ -51,7 +51,7 @@ function createClient(input: RecordingClientInput) {
|
|||||||
provider: {
|
provider: {
|
||||||
name: providerInput.name,
|
name: providerInput.name,
|
||||||
providerType: providerInput.providerType,
|
providerType: providerInput.providerType,
|
||||||
models: providerInput.options.models,
|
models: providerInput.options.models ?? [],
|
||||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||||
available: true,
|
available: true,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -159,7 +159,7 @@ describe("provider add", () => {
|
|||||||
provider: {
|
provider: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
providerType: input.providerType,
|
providerType: input.providerType,
|
||||||
models: input.options.models,
|
models: input.options.models ?? [],
|
||||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||||
available: true,
|
available: true,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -211,7 +211,7 @@ describe("provider add", () => {
|
|||||||
provider: {
|
provider: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
providerType: input.providerType,
|
providerType: input.providerType,
|
||||||
models: input.options.models,
|
models: input.options.models ?? [],
|
||||||
auth: { kind: "api_key", configured: false, source: "env" },
|
auth: { kind: "api_key", configured: false, source: "env" },
|
||||||
available: false,
|
available: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -258,7 +258,7 @@ describe("provider add", () => {
|
|||||||
provider: {
|
provider: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
providerType: input.providerType,
|
providerType: input.providerType,
|
||||||
models: input.options.models,
|
models: input.options.models ?? [],
|
||||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||||
available: true,
|
available: true,
|
||||||
error: null,
|
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 () => {
|
it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const stored: unknown[] = [];
|
const stored: unknown[] = [];
|
||||||
@@ -322,7 +373,7 @@ describe("provider add", () => {
|
|||||||
provider: {
|
provider: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
providerType: input.providerType,
|
providerType: input.providerType,
|
||||||
models: input.options.models,
|
models: input.options.models ?? [],
|
||||||
auth: { kind: "oauth", configured: false },
|
auth: { kind: "oauth", configured: false },
|
||||||
available: false,
|
available: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -493,7 +544,7 @@ describe("provider add", () => {
|
|||||||
provider: {
|
provider: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
providerType: input.providerType,
|
providerType: input.providerType,
|
||||||
models: input.options.models,
|
models: input.options.models ?? [],
|
||||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||||
available: true,
|
available: true,
|
||||||
error: null,
|
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 () => {
|
it("requires the catalog feature flag before reading the catalog", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|
||||||
@@ -601,7 +680,7 @@ describe("provider add", () => {
|
|||||||
provider: {
|
provider: {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
providerType: input.providerType,
|
providerType: input.providerType,
|
||||||
models: input.options.models,
|
models: input.options.models ?? [],
|
||||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||||
available: true,
|
available: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { loginOAuthBrowser } from "@getpaseo/server";
|
|||||||
import { connectToDaemon } from "../../utils/client.js";
|
import { connectToDaemon } from "../../utils/client.js";
|
||||||
import { collectMultiple } from "../../utils/command-options.js";
|
import { collectMultiple } from "../../utils/command-options.js";
|
||||||
import { openBrowserUrl } from "../../utils/open-browser.js";
|
import { openBrowserUrl } from "../../utils/open-browser.js";
|
||||||
|
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||||
import type {
|
import type {
|
||||||
CommandError,
|
CommandError,
|
||||||
CommandOptions,
|
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 {
|
function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined {
|
||||||
const auth = entry.auth;
|
const auth = entry.auth;
|
||||||
const value = auth[field];
|
const value = auth[field];
|
||||||
@@ -185,10 +174,10 @@ function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireModels(
|
function resolveModels(
|
||||||
entry: PaseoAgentCatalogEntry,
|
entry: PaseoAgentCatalogEntry,
|
||||||
options: ProviderAddOptions,
|
options: ProviderAddOptions,
|
||||||
): ProviderModelInput[] {
|
): ProviderModelInput[] | undefined {
|
||||||
const modelIds = normalizeModels(options.model);
|
const modelIds = normalizeModels(options.model);
|
||||||
if (modelIds.length > 0) {
|
if (modelIds.length > 0) {
|
||||||
return modelIds.map((id) => ({ id }));
|
return modelIds.map((id) => ({ id }));
|
||||||
@@ -198,12 +187,7 @@ function requireModels(
|
|||||||
if (models.length > 0) {
|
if (models.length > 0) {
|
||||||
return models;
|
return models;
|
||||||
}
|
}
|
||||||
throw {
|
return undefined;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectCatalogEntry(
|
async function selectCatalogEntry(
|
||||||
@@ -249,9 +233,10 @@ async function resolveEntry(
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const knownIds = catalog.map((candidate) => candidate.id).join(", ");
|
||||||
throw {
|
throw {
|
||||||
code: "UNKNOWN_PROVIDER",
|
code: "UNKNOWN_PROVIDER",
|
||||||
message: `Unknown model provider type "${id}".`,
|
message: `Unknown model provider type "${id}". Known provider ids: ${knownIds}.`,
|
||||||
} satisfies CommandError;
|
} satisfies CommandError;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +412,7 @@ async function configureProvider(
|
|||||||
options: ProviderAddOptions,
|
options: ProviderAddOptions,
|
||||||
dependencies: ProviderAddDependencies,
|
dependencies: ProviderAddDependencies,
|
||||||
): Promise<RedactedPaseoAgentProviderConfig> {
|
): Promise<RedactedPaseoAgentProviderConfig> {
|
||||||
const models = requireModels(entry, options);
|
const models = resolveModels(entry, options);
|
||||||
const apiKey =
|
const apiKey =
|
||||||
entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined;
|
entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined;
|
||||||
const result = await client.setPaseoAgentProvider({
|
const result = await client.setPaseoAgentProvider({
|
||||||
@@ -435,7 +420,7 @@ async function configureProvider(
|
|||||||
providerType: entry.id,
|
providerType: entry.id,
|
||||||
options: {
|
options: {
|
||||||
...(apiKey ? { apiKey } : {}),
|
...(apiKey ? { apiKey } : {}),
|
||||||
models,
|
...(models ? { models } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!result.success || !result.provider) {
|
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", () => {
|
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 () => {
|
it("lists configured model providers with catalog labels and auth states", async () => {
|
||||||
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
|
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
|
||||||
connectDaemon: async (options) => {
|
connectDaemon: async (options) => {
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import type { Command } from "commander";
|
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 { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type {
|
import type {
|
||||||
PaseoAgentCatalogEntry,
|
PaseoAgentCatalogEntry,
|
||||||
RedactedPaseoAgentProviderConfig,
|
RedactedPaseoAgentProviderConfig,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import { connectToDaemon } from "../../utils/client.js";
|
import { connectToDaemon } from "../../utils/client.js";
|
||||||
|
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||||
|
|
||||||
export interface ProviderListItem {
|
export interface ProviderListItem {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -39,6 +46,12 @@ export const providerLsSchema: OutputSchema<ProviderListItem> = {
|
|||||||
{ header: "AVAILABLE", field: "available", width: 10 },
|
{ header: "AVAILABLE", field: "available", width: 10 },
|
||||||
{ header: "MODELS", field: "models", width: 30 },
|
{ 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>;
|
export type ProviderLsResult = ListResult<ProviderListItem>;
|
||||||
@@ -47,18 +60,6 @@ export interface ProviderLsOptions extends CommandOptions {
|
|||||||
host?: string;
|
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 {
|
function authState(provider: RedactedPaseoAgentProviderConfig): string {
|
||||||
if (!provider.auth) {
|
if (!provider.auth) {
|
||||||
return "not configured";
|
return "not configured";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
OutputSchema,
|
OutputSchema,
|
||||||
SingleResult,
|
SingleResult,
|
||||||
} from "../../output/index.js";
|
} from "../../output/index.js";
|
||||||
|
import { requirePaseoAgentCatalogFeature } from "./feature.js";
|
||||||
|
|
||||||
interface ProviderRmOptions extends CommandOptions {
|
interface ProviderRmOptions extends CommandOptions {
|
||||||
host?: string;
|
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(
|
export async function runRmCommand(
|
||||||
name: string,
|
name: string,
|
||||||
options: ProviderRmOptions,
|
options: ProviderRmOptions,
|
||||||
|
|||||||
@@ -3,15 +3,18 @@
|
|||||||
/**
|
/**
|
||||||
* Phase 15: Provider Command Tests
|
* Phase 15: Provider Command Tests
|
||||||
*
|
*
|
||||||
* Tests provider commands for listing providers and models.
|
* Tests provider commands for configured Paseo Agent model providers and agent
|
||||||
* Provider ls data is static, while provider models are fetched via daemon integration.
|
* provider model listing. This test uses an isolated daemon to avoid coupling to
|
||||||
* This test uses an isolated daemon to avoid coupling to a user's long-running daemon.
|
* a user's long-running daemon.
|
||||||
*
|
*
|
||||||
* Tests:
|
* Tests:
|
||||||
* - provider --help shows subcommands
|
* - provider --help shows subcommands
|
||||||
* - provider ls lists all providers
|
* - provider ls on a fresh daemon prints an empty configured-provider table
|
||||||
* - provider ls --json outputs valid JSON
|
* - provider add stores an API-key model provider without network validation
|
||||||
* - provider ls --quiet outputs provider names only
|
* - 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 claude lists claude models
|
||||||
* - provider models codex lists codex models
|
* - provider models codex lists codex models
|
||||||
* - provider models opencode lists opencode models
|
* - provider models opencode lists opencode models
|
||||||
@@ -20,14 +23,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from "node:assert";
|
import assert from "node:assert";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||||
import { join } from "node:path";
|
|
||||||
import {
|
|
||||||
createE2ETestContext,
|
|
||||||
createTempDirs,
|
|
||||||
runPaseoCli,
|
|
||||||
startTestDaemon,
|
|
||||||
} from "./helpers/test-daemon.ts";
|
|
||||||
|
|
||||||
console.log("=== Provider Commands ===\n");
|
console.log("=== Provider Commands ===\n");
|
||||||
|
|
||||||
@@ -38,10 +34,12 @@ interface ProviderModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProviderListRow {
|
interface ProviderListRow {
|
||||||
provider: string;
|
name: string;
|
||||||
|
providerType: string;
|
||||||
label: string;
|
label: string;
|
||||||
status: string;
|
auth: string;
|
||||||
enabled: string;
|
available: string;
|
||||||
|
models: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EXPECTED_CLAUDE_MODELS = [
|
const EXPECTED_CLAUDE_MODELS = [
|
||||||
@@ -131,6 +129,24 @@ async function runProviderModelsJson(provider: string): Promise<ProviderModel[]>
|
|||||||
return attemptRun(1);
|
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 {
|
function assertClaudeModels(data: ProviderModel[]): void {
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
data.length,
|
data.length,
|
||||||
@@ -167,142 +183,135 @@ try {
|
|||||||
const result = await ctx.paseo(["provider", "--help"]);
|
const result = await ctx.paseo(["provider", "--help"]);
|
||||||
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
|
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
|
||||||
assert(result.stdout.includes("ls"), "help should mention ls");
|
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");
|
assert(result.stdout.includes("models"), "help should mention models");
|
||||||
console.log("✓ provider --help shows subcommands\n");
|
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"]);
|
const result = await ctx.paseo(["provider", "ls"]);
|
||||||
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
|
||||||
assert(result.stdout.includes("claude"), "output should include claude");
|
assertProviderTableHeader(result.stdout);
|
||||||
assert(result.stdout.includes("codex"), "output should include codex");
|
assert(!result.stdout.includes("OpenRouter"), "fresh output should have no provider rows");
|
||||||
assert(result.stdout.includes("opencode"), "output should include opencode");
|
|
||||||
assert(result.stdout.includes("ENABLED"), "output should include ENABLED column");
|
const jsonResult = await ctx.paseo(["provider", "ls", "--json"]);
|
||||||
assert(result.stdout.includes("Enabled"), "output should show enabled providers");
|
assert.strictEqual(jsonResult.exitCode, 0, "provider ls --json should exit 0");
|
||||||
assert(
|
assert.deepStrictEqual(parseProviderListJson(jsonResult.stdout), []);
|
||||||
result.stdout.includes("available") ||
|
console.log("✓ provider ls on a fresh daemon shows no configured providers\n");
|
||||||
result.stdout.includes("loading") ||
|
|
||||||
result.stdout.includes("unavailable"),
|
|
||||||
"output should show a provider status",
|
|
||||||
);
|
|
||||||
console.log("✓ provider ls lists all 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");
|
console.log("Test 3: provider add openrouter stores a dummy key without network validation");
|
||||||
const result = await ctx.paseo(["provider", "ls", "--json"]);
|
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||||
assert.strictEqual(result.exitCode, 0, "should exit 0");
|
stdin: "dummy-openrouter-key\n",
|
||||||
const data = JSON.parse(result.stdout.trim());
|
});
|
||||||
assert(Array.isArray(data), "output should be an array");
|
assert.strictEqual(result.exitCode, 0, `provider add should exit 0\n${result.stderr}`);
|
||||||
assert(data.length >= 3, `should have at least 3 providers, got ${data.length}`);
|
assert(result.stdout.includes("openrouter"), "add output should include the instance name");
|
||||||
assert(
|
assert(result.stdout.includes("OpenRouter"), "add output should include the catalog label");
|
||||||
data.some((p: { provider: string }) => p.provider === "claude"),
|
assert(result.stdout.includes("Connected"), "add output should show connected auth state");
|
||||||
"should include claude",
|
assert(result.stdout.includes("yes"), "add output should show the provider as available");
|
||||||
);
|
|
||||||
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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const omp = rows.find((p) => p.provider === "omp");
|
const rows = await getProviderRows();
|
||||||
assert(omp, "should include omp");
|
assert.strictEqual(rows.length, 1, "provider ls should show exactly one configured instance");
|
||||||
assert.strictEqual(omp.enabled, "Disabled", "omp should report Disabled by default");
|
assert.deepStrictEqual(rows[0], {
|
||||||
console.log("✓ provider ls --json outputs valid JSON\n");
|
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");
|
console.log("Test 4: provider add is idempotent for the same instance name");
|
||||||
const { paseoHome, workDir } = await createTempDirs();
|
const result = await ctx.paseo(["provider", "add", "openrouter", "--api-key-stdin"], {
|
||||||
await writeFile(
|
stdin: "dummy-openrouter-key-2\n",
|
||||||
join(paseoHome, "config.json"),
|
});
|
||||||
JSON.stringify(
|
assert.strictEqual(result.exitCode, 0, `provider add should exit 0\n${result.stderr}`);
|
||||||
{
|
|
||||||
version: 1,
|
const rows = await getProviderRows();
|
||||||
agents: {
|
assert.strictEqual(rows.length, 1, "re-running add should not create another instance");
|
||||||
providers: {
|
assert.strictEqual(rows[0]?.name, "openrouter");
|
||||||
claude: {
|
assert.strictEqual(rows[0]?.label, "OpenRouter");
|
||||||
enabled: false,
|
console.log("✓ provider add is idempotent for the same instance name\n");
|
||||||
},
|
}
|
||||||
},
|
|
||||||
},
|
// Test 5: provider add rejects unknown catalog ids with known ids
|
||||||
},
|
{
|
||||||
null,
|
console.log("Test 5: provider add rejects unknown catalog ids with known ids");
|
||||||
2,
|
const result = await ctx.paseo(["provider", "add", "nonsense-id", "--api-key-stdin"], {
|
||||||
) + "\n",
|
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",
|
||||||
);
|
);
|
||||||
|
console.log("✓ provider add uses catalog default models when present\n");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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");
|
console.log("Test 8: provider models claude lists canonical model aliases");
|
||||||
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");
|
|
||||||
const data = await runProviderModelsJson("claude");
|
const data = await runProviderModelsJson("claude");
|
||||||
assertClaudeModels(data);
|
assertClaudeModels(data);
|
||||||
console.log("✓ provider models claude lists canonical model aliases\n");
|
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");
|
const data = await runProviderModelsJson("codex");
|
||||||
assert(data.length >= 1, "codex model list should not be empty");
|
assert(data.length >= 1, "codex model list should not be empty");
|
||||||
const ids = data.map((m) => m.id);
|
const ids = data.map((m) => m.id);
|
||||||
@@ -322,9 +331,9 @@ try {
|
|||||||
console.log("✓ provider models codex includes concrete codex model IDs\n");
|
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");
|
const data = await runProviderModelsJson("opencode");
|
||||||
assert(data.length >= 1, "opencode model list should not be empty");
|
assert(data.length >= 1, "opencode model list should not be empty");
|
||||||
const ids = data.map((m) => m.id);
|
const ids = data.map((m) => m.id);
|
||||||
@@ -343,9 +352,9 @@ try {
|
|||||||
console.log("✓ provider models opencode returns namespaced model IDs\n");
|
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"]);
|
const result = await ctx.paseo(["provider", "models", "unknown"]);
|
||||||
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
|
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
|
||||||
const output = result.stdout + result.stderr;
|
const output = result.stdout + result.stderr;
|
||||||
@@ -356,9 +365,9 @@ try {
|
|||||||
console.log("✓ provider models unknown fails with error\n");
|
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");
|
const data = await runProviderModelsJson("claude");
|
||||||
assert(Array.isArray(data), "output should be an array");
|
assert(Array.isArray(data), "output should be an array");
|
||||||
assert(
|
assert(
|
||||||
@@ -371,9 +380,9 @@ try {
|
|||||||
console.log("✓ provider models --json outputs valid JSON\n");
|
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(
|
assert(
|
||||||
claudeModelIdsFromJson.length > 0,
|
claudeModelIdsFromJson.length > 0,
|
||||||
"claude model IDs should be captured from --json output",
|
"claude model IDs should be captured from --json output",
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ export async function runPaseoCli(
|
|||||||
timeout?: number;
|
timeout?: number;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
|
stdin?: string;
|
||||||
},
|
},
|
||||||
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
||||||
const timeout = options?.timeout ?? 60000;
|
const timeout = options?.timeout ?? 60000;
|
||||||
@@ -354,7 +355,7 @@ export async function runPaseoCli(
|
|||||||
...options?.env,
|
...options?.env,
|
||||||
},
|
},
|
||||||
cwd,
|
cwd,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: [options?.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,6 +370,10 @@ export async function runPaseoCli(
|
|||||||
appendOutputCapture(stderr, data);
|
appendOutputCapture(stderr, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (options?.stdin !== undefined) {
|
||||||
|
proc.stdin?.end(options.stdin);
|
||||||
|
}
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (proc.pid) {
|
if (proc.pid) {
|
||||||
signalProcessTree(proc.pid, "SIGKILL");
|
signalProcessTree(proc.pid, "SIGKILL");
|
||||||
@@ -406,7 +411,7 @@ export async function createE2ETestContext(options?: {
|
|||||||
/** Run a paseo CLI command against this daemon */
|
/** Run a paseo CLI command against this daemon */
|
||||||
paseo: (
|
paseo: (
|
||||||
args: string[],
|
args: string[],
|
||||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||||
) => Promise<{
|
) => Promise<{
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
stdout: string;
|
stdout: string;
|
||||||
@@ -418,7 +423,7 @@ export async function createE2ETestContext(options?: {
|
|||||||
|
|
||||||
const paseo = (
|
const paseo = (
|
||||||
args: string[],
|
args: string[],
|
||||||
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
opts?: { timeout?: number; cwd?: string; env?: NodeJS.ProcessEnv; stdin?: string },
|
||||||
) => runPaseoCli(ctx, args, opts);
|
) => runPaseoCli(ctx, args, opts);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ describe("Paseo Agent config RPC schemas", () => {
|
|||||||
expect(parsed.providerType).toBe("openrouter");
|
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)", () => {
|
test("parses a provider type this client has never heard of (new daemon, old client)", () => {
|
||||||
const parsed = SessionOutboundMessageSchema.parse({
|
const parsed = SessionOutboundMessageSchema.parse({
|
||||||
type: "config.paseo_agent.get_providers.response",
|
type: "config.paseo_agent.get_providers.response",
|
||||||
|
|||||||
@@ -1946,7 +1946,7 @@ const PaseoAgentSetProviderOptionsSchema = z
|
|||||||
api: z.string().min(1).optional(),
|
api: z.string().min(1).optional(),
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
authHeader: z.boolean().optional(),
|
authHeader: z.boolean().optional(),
|
||||||
models: z.array(PaseoAgentProviderModelConfigSchema).min(1),
|
models: z.array(PaseoAgentProviderModelConfigSchema).min(1).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -1020,6 +1020,65 @@ describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ProviderSnapshotManager applyPaseoAgentConfig", () => {
|
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 () => {
|
test("refreshes Paseo Agent models without daemon restart", async () => {
|
||||||
const manager = new ProviderSnapshotManager({
|
const manager = new ProviderSnapshotManager({
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
|
|||||||
@@ -585,7 +585,7 @@ export class ProviderSnapshotManager {
|
|||||||
defaultModeId: definition?.defaultModeId ?? null,
|
defaultModeId: definition?.defaultModeId ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!definition?.enabled || !current || current.status === "loading") {
|
if (!definition?.enabled) {
|
||||||
entries.set(provider, {
|
entries.set(provider, {
|
||||||
...metadata,
|
...metadata,
|
||||||
status: "unavailable",
|
status: "unavailable",
|
||||||
@@ -594,6 +594,14 @@ export class ProviderSnapshotManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!current || current.status === "loading") {
|
||||||
|
entries.set(provider, {
|
||||||
|
...metadata,
|
||||||
|
status: "loading",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
entries.set(provider, {
|
entries.set(provider, {
|
||||||
...current,
|
...current,
|
||||||
...metadata,
|
...metadata,
|
||||||
|
|||||||
@@ -110,6 +110,27 @@ describe("PaseoAgentConfigService", () => {
|
|||||||
expect(JSON.stringify(presentService.getProviders())).not.toContain("sk-env-secret");
|
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", () => {
|
test("rejects an unknown provider type with a clear error and persists nothing", () => {
|
||||||
const service = new PaseoAgentConfigService({
|
const service = new PaseoAgentConfigService({
|
||||||
paseoHome: home,
|
paseoHome: home,
|
||||||
|
|||||||
@@ -165,9 +165,9 @@ function redactedProviders(
|
|||||||
const provider: RedactedPaseoAgentProviderConfig = {
|
const provider: RedactedPaseoAgentProviderConfig = {
|
||||||
name,
|
name,
|
||||||
providerType: catalogEntry.id,
|
providerType: catalogEntry.id,
|
||||||
models: models.map((model) => ({ ...model })),
|
models: models.map((model) => Object.assign({}, model)),
|
||||||
auth,
|
auth,
|
||||||
available: auth.configured && models.length > 0,
|
available: auth.configured,
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
provider.baseUrl = settings.baseUrl;
|
provider.baseUrl = settings.baseUrl;
|
||||||
|
|||||||
Reference in New Issue
Block a user