From a410617d4a8a6b52821e3a1e67437f1eacc3c1bd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Jul 2026 08:37:42 +0200 Subject: [PATCH] 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. --- docs/paseo-agent.md | 5 ++-- packages/cli/src/commands/provider/index.ts | 6 +++-- .../src/messages.paseo-agent-config.test.ts | 23 ++++++++++++++++ packages/protocol/src/messages.ts | 26 +++++-------------- .../paseo-agent/config-service.test.ts | 16 ++++++++++++ .../providers/paseo-agent/config-service.ts | 10 +++++-- .../agent/providers/paseo-agent/config.ts | 10 +++++++ .../server/src/server/websocket-server.ts | 2 +- 8 files changed, 72 insertions(+), 26 deletions(-) diff --git a/docs/paseo-agent.md b/docs/paseo-agent.md index 26b350fbd..36aabbd9a 100644 --- a/docs/paseo-agent.md +++ b/docs/paseo-agent.md @@ -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 / ...`. -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 diff --git a/packages/cli/src/commands/provider/index.ts b/packages/cli/src/commands/provider/index.ts index 3173dec0b..43f566084 100644 --- a/packages/cli/src/commands/provider/index.ts +++ b/packages/cli/src/commands/provider/index.ts @@ -8,7 +8,9 @@ import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; export function createProviderCommand( dependencies: Parameters[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>["data"], [string]>( (name, options, command) => runAddOpenRouterCommand(name, options, command, dependencies), diff --git a/packages/protocol/src/messages.paseo-agent-config.test.ts b/packages/protocol/src/messages.paseo-agent-config.test.ts index 266513e07..0392bbedf 100644 --- a/packages/protocol/src/messages.paseo-agent-config.test.ts +++ b/packages/protocol/src/messages.paseo-agent-config.test.ts @@ -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", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index dc8bb9713..0c75b74c9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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(), diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts index 6132dca0d..999c8b7b5 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.test.ts @@ -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( diff --git a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts index 0ed8d5469..d5cbd0f57 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config-service.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config-service.ts @@ -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, diff --git a/packages/server/src/server/agent/providers/paseo-agent/config.ts b/packages/server/src/server/agent/providers/paseo-agent/config.ts index eb9d9afa6..b762e29c5 100644 --- a/packages/server/src/server/agent/providers/paseo-agent/config.ts +++ b/packages/server/src/server/agent/providers/paseo-agent/config.ts @@ -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; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7b5470e7a..ea8322583 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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, }, };