mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 07:04:45 +00:00
fix(coding-agent): document modelOverrides and harden override merging fixes #1062
This commit is contained in:
parent
bd646eece3
commit
6f897c3673
4 changed files with 114 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue