mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add additive custom provider models
This commit is contained in:
@@ -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<string, string>` | 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 |
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", [
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user