feat(plugin): provider plugins declare option controls and receive option changes

A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.
This commit is contained in:
Mohamed Boudra
2026-11-04 05:35:10 +01:00
parent a7c976679d
commit 5cbfef5f18
19 changed files with 519 additions and 183 deletions
+74 -2
View File
@@ -116,7 +116,9 @@ export interface ProviderSessionConfig {
model?: string;
mode?: string;
thinkingOption?: string;
/** @deprecated The same values as `providerOptions`. */
settings: Readonly<Record<string, JsonValue>>;
/** The agent's options, the provider defaults from `config.json` included. */
providerOptions?: Readonly<Record<string, unknown>>;
title?: string;
persist: boolean;
@@ -126,6 +128,13 @@ export interface ProviderConfigChanges {
model?: string | null;
mode?: string | null;
thinkingOption?: string | null;
/**
* The option keys to change on the running session. A key it cannot apply live is not in
* `controls`, and Paseo does not send it. `null` returns the key to the provider's own default.
* Apply the change or fail the request; the committed values come back in `session.config`.
*/
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */
settings?: Readonly<Record<string, JsonValue>>;
}
@@ -311,6 +320,34 @@ export interface ProviderMode {
isUnattended?: boolean;
}
/**
* Draws one top-level option key in the composer. The control holds no value: Paseo reads it
* from the agent's options.
*/
export type ProviderOptionControl =
| {
type: "toggle";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** Shown while the key has no value. */
defaultValue?: boolean;
}
| {
type: "select";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** The choice shown while the key has no value. */
defaultValue?: string;
choices: ReadonlyArray<{ value: string; label: string; description?: string }>;
};
/** @deprecated Report `controls` and `providerOptions` instead. */
export type ProviderSetting =
| {
type: "toggle";
@@ -335,7 +372,12 @@ export interface ProviderConfigState {
models: readonly ProviderModel[];
modes: readonly ProviderMode[];
thinkingOptions: readonly ProviderThinkingOption[];
settings: readonly ProviderSetting[];
/** The option keys that get a composer control. May change at runtime, for example per model. */
controls?: readonly ProviderOptionControl[];
/** What the session runs with now, for the keys it reports. Publish the committed values. */
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Report `controls` and `providerOptions`. Each setting id is an option key. */
settings?: readonly ProviderSetting[];
}
export interface ProviderCatalog {
@@ -888,6 +930,7 @@ const configChangesSchema = z
model: z.string().nullable().optional(),
mode: z.string().nullable().optional(),
thinkingOption: z.string().nullable().optional(),
providerOptions: jsonObjectSchema.optional(),
settings: jsonObjectSchema.optional(),
})
.strict();
@@ -1024,6 +1067,33 @@ const settingSchema = z.discriminatedUnion("type", [
})
.strip(),
]);
const controlSchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("toggle"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.boolean().optional(),
})
.strip(),
z
.object({
type: z.literal("select"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.string().optional(),
choices: z.array(
z
.object({ value: z.string(), label: z.string(), description: z.string().optional() })
.strip(),
),
})
.strip(),
]);
const configStateSchema = z
.object({
model: z.string().optional(),
@@ -1032,7 +1102,9 @@ const configStateSchema = z
models: z.array(modelSchema),
modes: z.array(modeSchema),
thinkingOptions: z.array(selectOptionSchema),
settings: z.array(settingSchema),
controls: z.array(controlSchema).optional(),
providerOptions: jsonObjectSchema.optional(),
settings: z.array(settingSchema).optional(),
})
.strip();
const catalogSchema = z