fix(coding-agent): document modelOverrides and harden override merging fixes #1062

This commit is contained in:
Mario Zechner 2026-02-06 18:53:54 +01:00
parent bd646eece3
commit 6f897c3673
4 changed files with 114 additions and 7 deletions

View file

@ -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

View file

@ -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 <apiKey>` 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:

View file

@ -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<Api>["compat"],
overrideCompat: ModelOverride["compat"],
): Model<Api>["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<Api>["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<Api>, override: ModelOverride): Model<A
}
// Deep merge compat
if (override.compat) {
result.compat = model.compat ? { ...model.compat, ...override.compat } : override.compat;
}
result.compat = mergeCompat(model.compat, override.compat);
return result;
}

View file

@ -1,6 +1,7 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { OpenAICompletionsCompat } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.js";
import { clearApiKeyCache, ModelRegistry } from "../src/core/model-registry.js";
@ -288,7 +289,8 @@ describe("ModelRegistry", () => {
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: {