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

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

View File

@@ -18,7 +18,7 @@ interface RecordingClientInput {
setProvider?: (input: {
name: string;
providerType: string;
options: { apiKey?: string; models: Array<{ id: string }> };
options: { apiKey?: string; models?: Array<{ id: string }> };
}) => Promise<unknown>;
startOAuth?: (name: string) => Promise<unknown>;
completeOAuth?: (name: string) => Promise<unknown>;
@@ -40,7 +40,7 @@ function createClient(input: RecordingClientInput) {
setPaseoAgentProvider: async (providerInput: {
name: string;
providerType: string;
options: { apiKey?: string; models: Array<{ id: string }> };
options: { apiKey?: string; models?: Array<{ id: string }> };
}) => {
if (input.setProvider) {
return input.setProvider(providerInput);
@@ -51,7 +51,7 @@ function createClient(input: RecordingClientInput) {
provider: {
name: providerInput.name,
providerType: providerInput.providerType,
models: providerInput.options.models,
models: providerInput.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
@@ -159,7 +159,7 @@ describe("provider add", () => {
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
@@ -211,7 +211,7 @@ describe("provider add", () => {
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: false, source: "env" },
available: false,
error: null,
@@ -258,7 +258,7 @@ describe("provider add", () => {
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
@@ -281,6 +281,57 @@ describe("provider add", () => {
]);
});
it("configures an API-key provider without model defaults", async () => {
const setCalls: unknown[] = [];
const result = await runAddCommand("alpha-key", { apiKeyStdin: true }, {} as never, {
readStdin: async () => "stdin-secret\n",
promptSecret: async () => {
throw new Error("prompt should not be used with --api-key-stdin");
},
promptText: async () => {
throw new Error("text prompt should not be used");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry({ models: [] })],
setProvider: async (input) => {
setCalls.push(input);
return {
requestId: "set-1",
success: true,
provider: {
name: input.name,
providerType: input.providerType,
models: [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
},
error: null,
};
},
}),
});
expect(setCalls).toEqual([
{
name: "alpha-key",
providerType: "alpha-key",
options: {
apiKey: "stdin-secret",
},
},
]);
expect(result.data).toMatchObject({
name: "alpha-key",
auth: "Connected",
available: "yes",
models: "-",
});
});
it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => {
const order: string[] = [];
const stored: unknown[] = [];
@@ -322,7 +373,7 @@ describe("provider add", () => {
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models,
models: input.options.models ?? [],
auth: { kind: "oauth", configured: false },
available: false,
error: null,
@@ -493,7 +544,7 @@ describe("provider add", () => {
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,
@@ -524,6 +575,34 @@ describe("provider add", () => {
]);
});
it("mentions known provider ids for an unknown catalog id", async () => {
await expect(
runAddCommand("missing-key", {}, {} as never, {
promptSecret: async () => {
throw new Error("prompt should not run");
},
promptText: async () => {
throw new Error("text prompt should not run");
},
readStdin: async () => {
throw new Error("stdin should not run");
},
write: () => {},
connectDaemon: async () =>
createClient({
catalog: [apiKeyEntry({ id: "alpha-key" }), apiKeyEntry({ id: "gamma-key" })],
setProvider: async () => {
throw new Error("set should not run");
},
}),
}),
).rejects.toMatchObject({
code: "UNKNOWN_PROVIDER",
message:
'Unknown model provider type "missing-key". Known provider ids: alpha-key, gamma-key.',
});
});
it("requires the catalog feature flag before reading the catalog", async () => {
const calls: string[] = [];
@@ -601,7 +680,7 @@ describe("provider add", () => {
provider: {
name: input.name,
providerType: input.providerType,
models: input.options.models,
models: input.options.models ?? [],
auth: { kind: "api_key", configured: true, source: "literal" },
available: true,
error: null,

View File

@@ -13,6 +13,7 @@ import { loginOAuthBrowser } from "@getpaseo/server";
import { connectToDaemon } from "../../utils/client.js";
import { collectMultiple } from "../../utils/command-options.js";
import { openBrowserUrl } from "../../utils/open-browser.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
import type {
CommandError,
CommandOptions,
@@ -127,18 +128,6 @@ async function promptSecret(message: string): Promise<string> {
}
}
function requirePaseoAgentCatalogFeature(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
): void {
if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) {
return;
}
throw {
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
} satisfies CommandError;
}
function authField(entry: PaseoAgentCatalogEntry, field: string): string | undefined {
const auth = entry.auth;
const value = auth[field];
@@ -185,10 +174,10 @@ function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] {
}));
}
function requireModels(
function resolveModels(
entry: PaseoAgentCatalogEntry,
options: ProviderAddOptions,
): ProviderModelInput[] {
): ProviderModelInput[] | undefined {
const modelIds = normalizeModels(options.model);
if (modelIds.length > 0) {
return modelIds.map((id) => ({ id }));
@@ -198,12 +187,7 @@ function requireModels(
if (models.length > 0) {
return models;
}
throw {
code: "MISSING_MODELS",
message: `At least one model is required for ${entry.label}.`,
details:
"Pass --model <model-id>. Repeat --model to configure more than one; comma-separated values are also accepted.",
} satisfies CommandError;
return undefined;
}
async function selectCatalogEntry(
@@ -249,9 +233,10 @@ async function resolveEntry(
return entry;
}
const knownIds = catalog.map((candidate) => candidate.id).join(", ");
throw {
code: "UNKNOWN_PROVIDER",
message: `Unknown model provider type "${id}".`,
message: `Unknown model provider type "${id}". Known provider ids: ${knownIds}.`,
} satisfies CommandError;
}
@@ -427,7 +412,7 @@ async function configureProvider(
options: ProviderAddOptions,
dependencies: ProviderAddDependencies,
): Promise<RedactedPaseoAgentProviderConfig> {
const models = requireModels(entry, options);
const models = resolveModels(entry, options);
const apiKey =
entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined;
const result = await client.setPaseoAgentProvider({
@@ -435,7 +420,7 @@ async function configureProvider(
providerType: entry.id,
options: {
...(apiKey ? { apiKey } : {}),
models,
...(models ? { models } : {}),
},
});
if (!result.success || !result.provider) {

View File

@@ -0,0 +1,17 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { CommandError } from "../../output/index.js";
export interface PaseoAgentCatalogFeatureClient extends Pick<
DaemonClient,
"getLastServerInfoMessage"
> {}
export function requirePaseoAgentCatalogFeature(client: PaseoAgentCatalogFeatureClient): void {
if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) {
return;
}
throw {
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
} satisfies CommandError;
}

View File

@@ -13,6 +13,30 @@ function createServerInfo() {
}
describe("provider ls", () => {
it("renders an empty configured-provider table with headers", async () => {
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
connectDaemon: async () => ({
getLastServerInfoMessage: createServerInfo,
getPaseoAgentCatalog: async () => ({
requestId: "catalog-1",
catalog: [],
error: null,
}),
getPaseoAgentProviders: async () => ({
requestId: "providers-1",
defaultModel: null,
providers: [],
error: null,
}),
close: async () => {},
}),
});
expect(result.data).toEqual([]);
expect(render(result, { format: "table", noColor: true })).toContain("NAME");
expect(render(result, { format: "json" })).toBe("[]");
});
it("lists configured model providers with catalog labels and auth states", async () => {
const result = await runLsCommand({ host: "localhost:7777" }, {} as never, {
connectDaemon: async (options) => {

View File

@@ -1,11 +1,18 @@
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import {
renderTable,
renderTableHeader,
type CommandOptions,
type ListResult,
type OutputSchema,
} from "../../output/index.js";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type {
PaseoAgentCatalogEntry,
RedactedPaseoAgentProviderConfig,
} from "@getpaseo/protocol/messages";
import { connectToDaemon } from "../../utils/client.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
export interface ProviderListItem {
name: string;
@@ -39,6 +46,12 @@ export const providerLsSchema: OutputSchema<ProviderListItem> = {
{ header: "AVAILABLE", field: "available", width: 10 },
{ header: "MODELS", field: "models", width: 30 },
],
renderHuman: (result, options) => {
if (result.type === "list" && result.data.length === 0) {
return options.noHeaders ? "" : renderTableHeader(providerLsSchema, options);
}
return renderTable(result, options);
},
};
export type ProviderLsResult = ListResult<ProviderListItem>;
@@ -47,18 +60,6 @@ export interface ProviderLsOptions extends CommandOptions {
host?: string;
}
function requirePaseoAgentCatalogFeature(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
): void {
if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) {
return;
}
throw {
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
};
}
function authState(provider: RedactedPaseoAgentProviderConfig): string {
if (!provider.auth) {
return "not configured";

View File

@@ -8,6 +8,7 @@ import type {
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { requirePaseoAgentCatalogFeature } from "./feature.js";
interface ProviderRmOptions extends CommandOptions {
host?: string;
@@ -39,18 +40,6 @@ export const providerRemoveSchema: OutputSchema<ProviderRemoveItem> = {
],
};
function requirePaseoAgentCatalogFeature(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
): void {
if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) {
return;
}
throw {
code: "HOST_UPDATE_REQUIRED",
message: "Update the Paseo daemon to use this command.",
} satisfies CommandError;
}
export async function runRmCommand(
name: string,
options: ProviderRmOptions,