diff --git a/docs/CUSTOM-PROVIDERS.md b/docs/CUSTOM-PROVIDERS.md index dedcdb60d..968914f0c 100644 --- a/docs/CUSTOM-PROVIDERS.md +++ b/docs/CUSTOM-PROVIDERS.md @@ -424,6 +424,48 @@ Models and modes are discovered dynamically at runtime from the agent process. I Profile models (defined in config.json) completely replace runtime-discovered models when present. +If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead. + +Example: add an experimental model while keeping every model the provider discovers at runtime: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent", "--acp"], + "additionalModels": [ + { "id": "experimental-model", "label": "Experimental", "isDefault": true } + ] + } + } + } +} +``` + +Example: relabel a discovered model without replacing the full list: + +```json +{ + "agents": { + "providers": { + "my-agent": { + "extends": "acp", + "label": "My Agent", + "command": ["my-agent", "--acp"], + "additionalModels": [ + { "id": "provider/model-id", "label": "My Preferred Label" } + ] + } + } + } +} +``` + +When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place. + --- ## Provider override reference @@ -438,6 +480,7 @@ Every entry under `agents.providers` accepts these fields: | `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process | | `env` | `Record` | No | Environment variables to set for the agent process | | `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) | +| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) | | `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) | | `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) | | `order` | `number` | No | Sort order in the provider list | diff --git a/packages/server/src/server/agent/provider-launch-config.ts b/packages/server/src/server/agent/provider-launch-config.ts index 770aca44f..d8ca845da 100644 --- a/packages/server/src/server/agent/provider-launch-config.ts +++ b/packages/server/src/server/agent/provider-launch-config.ts @@ -67,6 +67,7 @@ export const ProviderOverrideSchema = z command: z.array(z.string().min(1)).min(1).optional(), env: z.record(z.string()).optional(), models: z.array(ProviderProfileModelSchema).optional(), + additionalModels: z.array(ProviderProfileModelSchema).optional(), disallowedTools: z.array(z.string()).optional(), enabled: z.boolean().optional(), order: z.number().optional(), diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 0bdf2a63b..fddc8e995 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -666,6 +666,193 @@ describe("buildProviderRegistry", () => { ]); }); + test("additional models append to runtime models", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-pro", + label: "Runtime Pro", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + additionalModels: [ + { + id: "profile-fast", + label: "Profile Fast", + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models).toEqual([ + { + provider: "claude", + id: "runtime-pro", + label: "Runtime Pro", + }, + { + provider: "claude", + id: "profile-fast", + label: "Profile Fast", + }, + ]); + }); + + test("additional models merge onto profile replacement models", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-pro", + label: "Runtime Pro", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { + id: "profile-curated", + label: "Profile Curated", + }, + ], + additionalModels: [ + { + id: "profile-extra", + label: "Profile Extra", + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models.map((model) => model.id)).toEqual(["profile-curated", "profile-extra"]); + }); + + test("additional models override matching runtime models in place", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "shared-model", + label: "Runtime Label", + description: "Runtime description", + metadata: { + source: "runtime", + }, + }, + { + provider: "claude", + id: "runtime-only", + label: "Runtime Only", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + additionalModels: [ + { + id: "shared-model", + label: "Profile Label", + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models).toEqual([ + { + provider: "claude", + id: "shared-model", + label: "Profile Label", + description: "Runtime description", + metadata: { + source: "runtime", + }, + }, + { + provider: "claude", + id: "runtime-only", + label: "Runtime Only", + }, + ]); + }); + + test("additional model default overrides runtime default", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: true, + }, + { + provider: "claude", + id: "runtime-other", + label: "Runtime Other", + }, + ]); + + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + additionalModels: [ + { + id: "profile-default", + label: "Profile Default", + isDefault: true, + }, + ], + }, + }, + }); + + const models = await registry.claude.fetchModels({ + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models).toEqual([ + { + provider: "claude", + id: "runtime-default", + label: "Runtime Default", + isDefault: false, + }, + { + provider: "claude", + id: "runtime-other", + label: "Runtime Other", + isDefault: false, + }, + { + provider: "claude", + id: "profile-default", + label: "Profile Default", + isDefault: true, + }, + ]); + }); + test("no profile models — runtime models returned as-is", async () => { mockState.runtimeModels.set("claude", [ { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 3ebd17a19..1f04bb206 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -63,6 +63,7 @@ type ResolvedProvider = { definition: AgentProviderDefinition; runtimeSettings?: ProviderRuntimeSettings; profileModels: ProviderProfileModel[]; + additionalModels: ProviderProfileModel[]; enabled: boolean; createBaseClient: (logger: Logger) => AgentClient; }; @@ -224,16 +225,59 @@ function mapModel(provider: AgentProvider, model: AgentModelDefinition): AgentMo function mergeModels( provider: AgentProvider, profileModels: ProviderProfileModel[], + additionalModels: ProviderProfileModel[], runtimeModels: AgentModelDefinition[], ): AgentModelDefinition[] { - if (profileModels.length === 0) { - return runtimeModels.map((model) => mapModel(provider, model)); + const baseModels = + profileModels.length === 0 + ? runtimeModels.map((model) => mapModel(provider, model)) + : profileModels.map((model) => ({ + ...model, + provider, + })); + + if (additionalModels.length === 0) { + return baseModels; } - return profileModels.map((model) => ({ - ...model, - provider, - })); + const mergedModels = [...baseModels]; + let hasAdditionalDefault = false; + + for (const model of additionalModels) { + const additionalModel = { + ...model, + provider, + }; + hasAdditionalDefault ||= additionalModel.isDefault === true; + + const existingIndex = mergedModels.findIndex((candidate) => candidate.id === model.id); + if (existingIndex === -1) { + mergedModels.push(additionalModel); + continue; + } + + mergedModels[existingIndex] = { + ...mergedModels[existingIndex], + ...additionalModel, + }; + } + + if (!hasAdditionalDefault) { + return mergedModels; + } + + const additionalDefaultIds = new Set( + additionalModels.filter((model) => model.isDefault === true).map((model) => model.id), + ); + + return mergedModels.map((model) => + additionalDefaultIds.has(model.id) + ? model + : { + ...model, + isDefault: false, + }, + ); } function wrapSessionProvider(provider: AgentProvider, inner: AgentSession): AgentSession { @@ -330,7 +374,12 @@ function createRegistryEntry( return inner.provider === provider ? inner : wrapClientProvider(provider, inner); }, fetchModels: async (options: ListModelsOptions) => - mergeModels(provider, resolved.profileModels, await modelClient.listModels(options)), + mergeModels( + provider, + resolved.profileModels, + resolved.additionalModels, + await modelClient.listModels(options), + ), fetchModes: async (options: ListModesOptions) => { const modes = modelClient.listModes ? await modelClient.listModes(options) @@ -373,6 +422,7 @@ function buildResolvedBuiltinProviders( definition: applyOverrideToDefinition(definition, override), runtimeSettings: mergedRuntimeSettings, profileModels: override?.models ?? [], + additionalModels: override?.additionalModels ?? [], enabled: override?.enabled !== false, createBaseClient: (logger) => factory(logger, mergedRuntimeSettings, { @@ -416,6 +466,7 @@ function addDerivedProviders( ), runtimeSettings: toRuntimeSettings(override), profileModels: override.models ?? [], + additionalModels: override.additionalModels ?? [], enabled: override.enabled !== false, createBaseClient: (logger) => new GenericACPAgentClient({ @@ -445,6 +496,7 @@ function addDerivedProviders( definition: createDerivedDefinition(providerId, baseDefinition, override), runtimeSettings: mergedRuntimeSettings, profileModels: override.models ?? [], + additionalModels: override.additionalModels ?? [], enabled: override.enabled !== false, createBaseClient: (logger) => baseFactory(logger, mergedRuntimeSettings), }); diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index cbeea46ad..636cbe32b 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -165,6 +165,34 @@ describe("provider overrides (new format)", () => { ]); }); + test("additionalModels array accepted", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + providers: { + zai: { + extends: "claude", + label: "ZAI", + additionalModels: [ + { + id: "zai-fast", + label: "ZAI Fast", + isDefault: true, + }, + ], + }, + }, + }, + }); + + expect(parsed.agents?.providers?.zai?.additionalModels).toEqual([ + { + id: "zai-fast", + label: "ZAI Fast", + isDefault: true, + }, + ]); + }); + test("order field accepted", () => { const parsed = PersistedConfigSchema.parse({ agents: { diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index c9b74032a..7f56eb511 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -29,7 +29,6 @@ ] }, "allowedHosts": { - "description": "Deprecated: use hostnames instead", "anyOf": [ { "type": "boolean", @@ -48,9 +47,12 @@ "properties": { "enabled": { "type": "boolean" + }, + "injectIntoAgents": { + "type": "boolean" } }, - "additionalProperties": false + "additionalProperties": true }, "cors": { "type": "object", @@ -125,68 +127,95 @@ "additionalProperties": { "type": "object", "properties": { + "extends": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, "command": { - "anyOf": [ - { - "type": "object", - "properties": { - "mode": { - "type": "string", - "const": "default" - } - }, - "required": ["mode"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "mode": { - "type": "string", - "const": "append" - }, - "args": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["mode"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "mode": { - "type": "string", - "const": "replace" - }, - "argv": { - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "minItems": 1 - } - }, - "required": ["mode", "argv"], - "additionalProperties": false - } - ] + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 }, "env": { "type": "object", "additionalProperties": { "type": "string" } + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "label": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + }, + "thinkingOptions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "isDefault": { + "type": "boolean" + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + } + }, + "required": ["id", "label"], + "additionalProperties": false + } + }, + "additionalModels": { + "type": "array", + "items": { + "$ref": "#/definitions/PaseoConfigV1/properties/agents/properties/providers/additionalProperties/properties/models/items" + } + }, + "disallowedTools": { + "type": "array", + "items": { + "type": "string" + } + }, + "enabled": { + "type": "boolean" + }, + "order": { + "type": "number" } }, "additionalProperties": false - }, - "propertyNames": { - "type": "string" } } }, @@ -260,6 +289,15 @@ }, "additionalProperties": false }, + "turnDetection": { + "type": "object", + "properties": { + "provider": { + "$ref": "#/definitions/PaseoConfigV1/properties/features/properties/dictation/properties/stt/properties/provider" + } + }, + "additionalProperties": false + }, "tts": { "type": "object", "properties": {