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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user