Open the model provider type wire schema

A closed z.enum inside the strict get_providers payload meant any provider
type added daemon-side failed the entire WS envelope parse on older clients,
silently dropping the response and timing out the settings sheet. Make
providerType an open string on the wire; the daemon's config schema keeps the
closed set and setProvider rejects unknown types with a clear error. Also
correct the paseoAgentConfig COMPAT version, the provider CLI descriptions,
and the stale no-app-UI doc claim.
This commit is contained in:
Mohamed Boudra
2026-07-02 08:37:42 +02:00
parent 4a8a841f72
commit a410617d4a
8 changed files with 72 additions and 26 deletions

View File

@@ -4,8 +4,9 @@ Paseo Agent is a built-in agent provider that runs Pi's coding-agent harness **i
The provider id is **`paseo`** (the display name is "Paseo Agent"). Use it like any other agent provider, e.g. `paseo run --provider paseo --model <modelProviderName>/<modelId> ...`.
This is a prototype. There is no app UI yet. OpenRouter and ChatGPT setup have CLI
paths; other provider setup is still config-file based.
This is a prototype. The app Settings sheet can add OpenRouter model providers;
ChatGPT login and OpenRouter setup also have CLI paths. Other model provider
types are still config-file based.
> Smoke note: the daemon supervisor runs from `packages/server/dist`. After changing
> provider/config code, run `npm run build:server` (or run a source/dev daemon) before

View File

@@ -8,7 +8,9 @@ import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createProviderCommand(
dependencies: Parameters<typeof runAddOpenRouterCommand>[3] = {},
): Command {
const provider = new Command("provider").description("Manage agent providers");
const provider = new Command("provider").description(
"Manage agent providers (ls, models) and Paseo Agent model providers (add)",
);
addJsonAndDaemonHostOptions(
provider.command("ls").description("List available providers and status"),
@@ -22,7 +24,7 @@ export function createProviderCommand(
.option("--thinking", "Include thinking option IDs for each model"),
).action(withOutput(runModelsCommand));
const add = provider.command("add").description("Configure a provider");
const add = provider.command("add").description("Configure a Paseo Agent model provider");
addJsonAndDaemonHostOptions(addOpenRouterOptions(add.command("openrouter"))).action(
withOutput<Awaited<ReturnType<typeof runAddOpenRouterCommand>>["data"], [string]>(
(name, options, command) => runAddOpenRouterCommand(name, options, command, dependencies),

View File

@@ -19,6 +19,29 @@ describe("Paseo Agent config RPC schemas", () => {
expect(parsed.providerType).toBe("openrouter");
});
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",
payload: {
requestId: "req-get-future",
defaultModel: null,
providers: [
{
name: "kimi-main",
providerType: "kimi-coding",
models: [{ id: "kimi-k3" }],
auth: { kind: "api_key", configured: true, source: "env" },
available: true,
error: null,
},
],
error: null,
},
});
expect(parsed.payload.providers[0]?.providerType).toBe("kimi-coding");
});
test("parses redacted provider responses without raw secret fields", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "config.paseo_agent.get_providers.response",

View File

@@ -1922,15 +1922,11 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
requestId: z.string(),
});
const PaseoAgentProviderTypeSchema = z.enum([
"openrouter",
"openai",
"anthropic",
"opencode",
"openai-compatible",
"openai-codex",
"custom",
]);
// Open on the wire on purpose: the daemon's paseo-agent config schema owns the
// closed set of known types. A new type added daemon-side must not break an
// older client's envelope parse (protocol contract: never narrow, old clients
// keep parsing new daemons).
const PaseoAgentProviderTypeSchema = z.string().min(1);
const PaseoAgentProviderModelConfigSchema = z
.object({
@@ -1978,15 +1974,7 @@ export const PaseoAgentProviderAuthStateSchema = z
export const RedactedPaseoAgentProviderConfigSchema = z
.object({
name: z.string().min(1),
providerType: z.enum([
"openrouter",
"openai",
"anthropic",
"opencode",
"openai-compatible",
"openai-codex",
"custom",
]),
providerType: PaseoAgentProviderTypeSchema,
baseUrl: z.string().optional(),
api: z.string().optional(),
models: z.array(PaseoAgentProviderModelConfigSchema),
@@ -2468,7 +2456,7 @@ export const ServerInfoStatusPayloadSchema = z
daemonSelfUpdate: z.boolean().optional(),
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: z.boolean().optional(),
// COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30.
// COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02.
paseoAgentConfig: z.boolean().optional(),
})
.optional(),

View File

@@ -55,6 +55,22 @@ describe("PaseoAgentConfigService", () => {
);
});
test("rejects an unknown provider type with a clear error and persists nothing", () => {
const service = new PaseoAgentConfigService({
paseoHome: home,
logger: createTestLogger(),
});
expect(() =>
service.setProvider({
name: "future-main",
providerType: "kimi-coding",
options: { apiKey: "sk-test", models: [{ id: "kimi-k3" }] },
}),
).toThrow(/Unknown model provider type "kimi-coding". Known types: openrouter/);
expect(loadPersistedConfig(home).agents?.paseo?.providers).toBeUndefined();
});
test("preserves shared config fields when writing agents.paseo", () => {
const logger = createTestLogger();
savePersistedConfig(

View File

@@ -13,7 +13,8 @@ import {
import {
PaseoAgentConfigSchema,
type PaseoAgentConfig,
type PaseoAgentProviderType,
isPaseoAgentProviderType,
knownPaseoAgentProviderTypes,
resolvePaseoAgentProviderTypeDefaults,
} from "./config.js";
import { hasStoredOAuthCredential, storeCodexOAuthCredential } from "./oauth-store.js";
@@ -29,7 +30,7 @@ interface PaseoAgentConfigServiceOptions {
interface SetProviderInput {
name: string;
providerType: PaseoAgentProviderType;
providerType: string;
options: {
apiKey?: string;
baseUrl?: string;
@@ -163,6 +164,11 @@ export class PaseoAgentConfigService {
}
setProvider(input: SetProviderInput): RedactedPaseoAgentProviderConfig {
if (!isPaseoAgentProviderType(input.providerType)) {
throw new Error(
`Unknown model provider type "${input.providerType}". Known types: ${knownPaseoAgentProviderTypes().join(", ")}. Update the host if this type is newer than it.`,
);
}
const next = this.updateConfig((current) =>
PaseoAgentConfigSchema.parse({
...current,

View File

@@ -44,6 +44,16 @@ const PROVIDER_TYPES = [
export type PaseoAgentProviderType = (typeof PROVIDER_TYPES)[number];
// The wire schema is an open string (protocol back-compat); the daemon owns
// the closed set. Use this to gate incoming provider types with a clear error.
export function isPaseoAgentProviderType(value: string): value is PaseoAgentProviderType {
return (PROVIDER_TYPES as readonly string[]).includes(value);
}
export function knownPaseoAgentProviderTypes(): readonly string[] {
return PROVIDER_TYPES;
}
export interface PaseoAgentProviderTypeDefault {
/** Pi wire protocol. `undefined` for `custom`, where the user must pick one. */
api?: string;

View File

@@ -1225,7 +1225,7 @@ export class VoiceAssistantWebSocketServer {
daemonSelfUpdate: true,
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: true,
// COMPAT(paseoAgentConfig): added in v0.1.85, remove gate after 2026-11-30.
// COMPAT(paseoAgentConfig): added in v0.1.103, remove gate after 2027-01-02.
paseoAgentConfig: true,
},
};