From 6f897c36734a75119d97878c3757ea84e51aca21 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 6 Feb 2026 18:53:54 +0100 Subject: [PATCH] fix(coding-agent): document modelOverrides and harden override merging fixes #1062 --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/docs/models.md | 33 +++++++++++++ .../coding-agent/src/core/model-registry.ts | 37 +++++++++++++-- .../coding-agent/test/model-registry.test.ts | 47 +++++++++++++++++-- 4 files changed, 114 insertions(+), 7 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5d7e5dd..3eabfc53 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `modelOverrides` in `models.json` to customize individual built-in models per provider without full provider replacement ([#1332](https://github.com/badlogic/pi-mono/pull/1332) by [@charles-cooper](https://github.com/charles-cooper)) + ### Fixed - Fixed extra spacing between thinking-only assistant content and subsequent tool execution blocks when assistant messages contain no text diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index e7317eb3..66c28179 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -10,6 +10,7 @@ Add custom providers and models (Ollama, vLLM, LM Studio, proxies) via `~/.pi/ag - [Provider Configuration](#provider-configuration) - [Model Configuration](#model-configuration) - [Overriding Built-in Providers](#overriding-built-in-providers) +- [Per-model Overrides](#per-model-overrides) - [OpenAI Compatibility](#openai-compatibility) ## Minimal Example @@ -84,6 +85,7 @@ Set `api` at provider level (default for all models) or model level (override pe | `headers` | Custom headers (see value resolution below) | | `authHeader` | Set `true` to add `Authorization: Bearer ` automatically | | `models` | Array of model configurations | +| `modelOverrides` | Per-model overrides for built-in models on this provider | ### Value Resolution @@ -166,6 +168,37 @@ To fully replace a built-in provider with custom models, include the `models` ar } ``` +## Per-model Overrides + +Use `modelOverrides` to customize specific built-in models without replacing the provider's full model list. + +```json +{ + "providers": { + "openrouter": { + "modelOverrides": { + "anthropic/claude-sonnet-4": { + "name": "Claude Sonnet 4 (Bedrock Route)", + "compat": { + "openRouterRouting": { + "only": ["amazon-bedrock"] + } + } + } + } + } + } +} +``` + +`modelOverrides` supports these fields per model: `name`, `reasoning`, `input`, `cost` (partial), `contextWindow`, `maxTokens`, `headers`, `compat`. + +Behavior notes: +- Overrides are applied only to models that exist for that provider. +- Unknown model IDs are ignored. +- You can combine provider-level `baseUrl`/`headers` with `modelOverrides`. +- If `models` is defined for a provider (full replacement), built-in models are removed first. `modelOverrides` entries that do not match the resulting provider model list are ignored. + ## OpenAI Compatibility For providers with partial OpenAI compatibility, use the `compat` field: diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 6f9cb383..f8cda00a 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -11,6 +11,8 @@ import { type KnownProvider, type Model, type OAuthProviderInterface, + type OpenAICompletionsCompat, + type OpenAIResponsesCompat, registerApiProvider, registerOAuthProvider, type SimpleStreamOptions, @@ -141,6 +143,37 @@ function emptyCustomModelsResult(error?: string): CustomModelsResult { return { models: [], replacedProviders: new Set(), overrides: new Map(), modelOverrides: new Map(), error }; } +function mergeCompat( + baseCompat: Model["compat"], + overrideCompat: ModelOverride["compat"], +): Model["compat"] | undefined { + if (!overrideCompat) return baseCompat; + + const base = baseCompat as OpenAICompletionsCompat | OpenAIResponsesCompat | undefined; + const override = overrideCompat as OpenAICompletionsCompat | OpenAIResponsesCompat; + const merged = { ...base, ...override } as OpenAICompletionsCompat | OpenAIResponsesCompat; + + const baseCompletions = base as OpenAICompletionsCompat | undefined; + const overrideCompletions = override as OpenAICompletionsCompat; + const mergedCompletions = merged as OpenAICompletionsCompat; + + if (baseCompletions?.openRouterRouting || overrideCompletions.openRouterRouting) { + mergedCompletions.openRouterRouting = { + ...baseCompletions?.openRouterRouting, + ...overrideCompletions.openRouterRouting, + }; + } + + if (baseCompletions?.vercelGatewayRouting || overrideCompletions.vercelGatewayRouting) { + mergedCompletions.vercelGatewayRouting = { + ...baseCompletions?.vercelGatewayRouting, + ...overrideCompletions.vercelGatewayRouting, + }; + } + + return merged as Model["compat"]; +} + /** * Deep merge a model override into a model. * Handles nested objects (cost, compat) by merging rather than replacing. @@ -172,9 +205,7 @@ function applyModelOverride(model: Model, override: ModelOverride): Model { const models = getModelsForProvider(registry, "openrouter"); const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); - expect((sonnet?.compat as any)?.openRouterRouting).toEqual({ only: ["amazon-bedrock"] }); + const compat = sonnet?.compat as OpenAICompletionsCompat | undefined; + expect(compat?.openRouterRouting).toEqual({ only: ["amazon-bedrock"] }); }); test("model override deep merges compat settings", () => { @@ -309,7 +311,8 @@ describe("ModelRegistry", () => { const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); // Should have both the new routing AND preserve other compat settings - expect((sonnet?.compat as any)?.openRouterRouting).toEqual({ order: ["anthropic", "together"] }); + const compat = sonnet?.compat as OpenAICompletionsCompat | undefined; + expect(compat?.openRouterRouting).toEqual({ order: ["anthropic", "together"] }); }); test("multiple model overrides on same provider", () => { @@ -332,8 +335,10 @@ describe("ModelRegistry", () => { const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); const opus = models.find((m) => m.id === "anthropic/claude-opus-4"); - expect((sonnet?.compat as any)?.openRouterRouting).toEqual({ only: ["amazon-bedrock"] }); - expect((opus?.compat as any)?.openRouterRouting).toEqual({ only: ["anthropic"] }); + const sonnetCompat = sonnet?.compat as OpenAICompletionsCompat | undefined; + const opusCompat = opus?.compat as OpenAICompletionsCompat | undefined; + expect(sonnetCompat?.openRouterRouting).toEqual({ only: ["amazon-bedrock"] }); + expect(opusCompat?.openRouterRouting).toEqual({ only: ["anthropic"] }); }); test("model override combined with baseUrl override", () => { @@ -362,6 +367,40 @@ describe("ModelRegistry", () => { expect(opus?.name).not.toBe("Proxied Sonnet"); }); + test("model overrides are ignored when provider fully replaces built-in models", () => { + writeRawModelsJson({ + openrouter: { + baseUrl: "https://my-proxy.example.com/v1", + apiKey: "OPENROUTER_API_KEY", + api: "openai-completions", + models: [ + { + id: "custom/openrouter-model", + name: "Custom OpenRouter Model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 16384, + }, + ], + modelOverrides: { + "anthropic/claude-sonnet-4": { + name: "Ignored Sonnet Override", + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + + expect(models).toHaveLength(1); + expect(models[0].id).toBe("custom/openrouter-model"); + expect(models[0].name).toBe("Custom OpenRouter Model"); + expect(registry.getError()).toBeUndefined(); + }); + test("model override for non-existent model ID is ignored", () => { writeRawModelsJson({ openrouter: {