From 76a6a745171c12af02e327190ed0c9db87745bd4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 6 Feb 2026 19:11:38 +0100 Subject: [PATCH] feat(coding-agent): merge custom models with built-ins by id --- packages/coding-agent/CHANGELOG.md | 10 ++ packages/coding-agent/docs/models.md | 12 +- .../coding-agent/src/core/model-registry.ts | 99 ++++++------ .../coding-agent/test/model-registry.test.ts | 148 +++++++++--------- 4 files changed, 147 insertions(+), 122 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 3eabfc53..17508c41 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,15 +2,25 @@ ## [Unreleased] +### Breaking Changes + +- Changed `models.json` provider `models` behavior from full replacement to merge-by-id with built-in models. Built-in models are now kept by default, and custom models upsert by `id`. + ### 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)) +- Added `AWS_BEDROCK_SKIP_AUTH` and `AWS_BEDROCK_FORCE_HTTP1` environment variables for connecting to unauthenticated Bedrock proxies ([#1320](https://github.com/badlogic/pi-mono/pull/1320) by [@virtuald](https://github.com/virtuald)) ### Fixed - Fixed extra spacing between thinking-only assistant content and subsequent tool execution blocks when assistant messages contain no text - Fixed queued steering/follow-up/custom messages remaining stuck after threshold auto-compaction by resuming the agent loop when Agent-level queues still contain pending messages ([#1312](https://github.com/badlogic/pi-mono/pull/1312) by [@ferologics](https://github.com/ferologics)) - Fixed `tool_result` extension handlers to chain result patches across handlers instead of last-handler-wins behavior ([#1280](https://github.com/badlogic/pi-mono/issues/1280)) +- Fixed compromised auth lock files being handled gracefully instead of crashing auth storage initialization ([#1322](https://github.com/badlogic/pi-mono/issues/1322)) +- Fixed Bedrock adaptive thinking handling for Claude Opus 4.6 with interleaved thinking beta responses ([#1323](https://github.com/badlogic/pi-mono/pull/1323) by [@markusylisiurunen](https://github.com/markusylisiurunen)) +- Fixed OpenAI Responses API requests to use `store: false` by default to avoid server-side history logging ([#1308](https://github.com/badlogic/pi-mono/issues/1308)) +- Fixed interactive mode startup by initializing autocomplete after resources are loaded ([#1328](https://github.com/badlogic/pi-mono/issues/1328)) +- Fixed `modelOverrides` merge behavior for nested objects and documented usage details ([#1062](https://github.com/badlogic/pi-mono/issues/1062)) ## [0.52.6] - 2026-02-05 diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 66c28179..20b21871 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -153,7 +153,7 @@ Route a built-in provider through a proxy without redefining models: All built-in Anthropic models remain available. Existing OAuth or API key auth continues to work. -To fully replace a built-in provider with custom models, include the `models` array: +To merge custom models into a built-in provider, include the `models` array: ```json { @@ -168,6 +168,12 @@ To fully replace a built-in provider with custom models, include the `models` ar } ``` +Merge semantics: +- Built-in models are kept. +- Custom models are upserted by `id` within the provider. +- If a custom model `id` matches a built-in model `id`, the custom model replaces that built-in model. +- If a custom model `id` is new, it is added alongside built-in models. + ## Per-model Overrides Use `modelOverrides` to customize specific built-in models without replacing the provider's full model list. @@ -194,10 +200,10 @@ Use `modelOverrides` to customize specific built-in models without replacing the `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. +- `modelOverrides` are applied to built-in provider models. - 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. +- If `models` is also defined for a provider, custom models are merged after built-in overrides. A custom model with the same `id` replaces the overridden built-in model entry. ## OpenAI Compatibility diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index f8cda00a..574f4e98 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -130,9 +130,7 @@ interface ProviderOverride { /** Result of loading custom models from models.json */ interface CustomModelsResult { models: Model[]; - /** Providers with custom models (full replacement) */ - replacedProviders: Set; - /** Providers with only baseUrl/headers override (no custom models) */ + /** Providers with baseUrl/headers/apiKey overrides for built-in models */ overrides: Map; /** Per-model overrides: provider -> modelId -> override */ modelOverrides: Map>; @@ -140,7 +138,7 @@ interface CustomModelsResult { } function emptyCustomModelsResult(error?: string): CustomModelsResult { - return { models: [], replacedProviders: new Set(), overrides: new Map(), modelOverrides: new Map(), error }; + return { models: [], overrides: new Map(), modelOverrides: new Map(), error }; } function mergeCompat( @@ -260,10 +258,9 @@ export class ModelRegistry { } private loadModels(): void { - // Load custom models from models.json first (to know which providers to skip/override) + // Load custom models and overrides from models.json const { models: customModels, - replacedProviders, overrides, modelOverrides, error, @@ -274,8 +271,8 @@ export class ModelRegistry { // Keep built-in models even if custom models failed to load } - const builtInModels = this.loadBuiltInModels(replacedProviders, overrides, modelOverrides); - let combined = [...builtInModels, ...customModels]; + const builtInModels = this.loadBuiltInModels(overrides, modelOverrides); + let combined = this.mergeCustomModels(builtInModels, customModels); // Let OAuth providers modify their models (e.g., update baseUrl) for (const oauthProvider of this.authStorage.getOAuthProviders()) { @@ -288,41 +285,52 @@ export class ModelRegistry { this.models = combined; } - /** Load built-in models, skipping replaced providers and applying overrides */ + /** Load built-in models and apply provider/model overrides */ private loadBuiltInModels( - replacedProviders: Set, overrides: Map, modelOverrides: Map>, ): Model[] { - return getProviders() - .filter((provider) => !replacedProviders.has(provider)) - .flatMap((provider) => { - const models = getModels(provider as KnownProvider) as Model[]; - const providerOverride = overrides.get(provider); - const perModelOverrides = modelOverrides.get(provider); + return getProviders().flatMap((provider) => { + const models = getModels(provider as KnownProvider) as Model[]; + const providerOverride = overrides.get(provider); + const perModelOverrides = modelOverrides.get(provider); - return models.map((m) => { - let model = m; + return models.map((m) => { + let model = m; - // Apply provider-level baseUrl/headers override - if (providerOverride) { - const resolvedHeaders = resolveHeaders(providerOverride.headers); - model = { - ...model, - baseUrl: providerOverride.baseUrl ?? model.baseUrl, - headers: resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers, - }; - } + // Apply provider-level baseUrl/headers override + if (providerOverride) { + const resolvedHeaders = resolveHeaders(providerOverride.headers); + model = { + ...model, + baseUrl: providerOverride.baseUrl ?? model.baseUrl, + headers: resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers, + }; + } - // Apply per-model override - const modelOverride = perModelOverrides?.get(m.id); - if (modelOverride) { - model = applyModelOverride(model, modelOverride); - } + // Apply per-model override + const modelOverride = perModelOverrides?.get(m.id); + if (modelOverride) { + model = applyModelOverride(model, modelOverride); + } - return model; - }); + return model; }); + }); + } + + /** Merge custom models into built-in list by provider+id (custom wins on conflicts). */ + private mergeCustomModels(builtInModels: Model[], customModels: Model[]): Model[] { + const merged = [...builtInModels]; + for (const customModel of customModels) { + const existingIndex = merged.findIndex((m) => m.provider === customModel.provider && m.id === customModel.id); + if (existingIndex >= 0) { + merged[existingIndex] = customModel; + } else { + merged.push(customModel); + } + } + return merged; } private loadCustomModels(modelsJsonPath: string): CustomModelsResult { @@ -347,35 +355,30 @@ export class ModelRegistry { // Additional validation this.validateConfig(config); - // Separate providers into "full replacement" (has models) vs "override-only" (no models) - const replacedProviders = new Set(); const overrides = new Map(); const modelOverrides = new Map>(); for (const [providerName, providerConfig] of Object.entries(config.providers)) { - if (providerConfig.models && providerConfig.models.length > 0) { - // Has custom models -> full replacement - replacedProviders.add(providerName); - } else { - // No models -> just override baseUrl/headers on built-in + // Apply provider-level baseUrl/headers/apiKey override to built-in models when configured. + if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey) { overrides.set(providerName, { baseUrl: providerConfig.baseUrl, headers: providerConfig.headers, apiKey: providerConfig.apiKey, }); - // Store API key for fallback resolver - if (providerConfig.apiKey) { - this.customProviderApiKeys.set(providerName, providerConfig.apiKey); - } } - // Collect per-model overrides (works with both full replacement and override-only) + // Store API key for fallback resolver. + if (providerConfig.apiKey) { + this.customProviderApiKeys.set(providerName, providerConfig.apiKey); + } + if (providerConfig.modelOverrides) { modelOverrides.set(providerName, new Map(Object.entries(providerConfig.modelOverrides))); } } - return { models: this.parseModels(config), replacedProviders, overrides, modelOverrides, error: undefined }; + return { models: this.parseModels(config), overrides, modelOverrides, error: undefined }; } catch (error) { if (error instanceof SyntaxError) { return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`); @@ -399,7 +402,7 @@ export class ModelRegistry { throw new Error(`Provider ${providerName}: must specify "baseUrl", "modelOverrides", or "models".`); } } else { - // Full replacement: needs baseUrl and apiKey + // Custom models are merged into provider models and require endpoint + auth. if (!providerConfig.baseUrl) { throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`); } diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index f20ac8c1..bb084b13 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -121,11 +121,11 @@ describe("ModelRegistry", () => { expect(googleModels[0].baseUrl).not.toBe("https://my-proxy.example.com/v1"); }); - test("can mix baseUrl override and full replacement", () => { + test("can mix baseUrl override and models merge", () => { writeRawModelsJson({ // baseUrl-only for anthropic anthropic: overrideConfig("https://anthropic-proxy.example.com/v1"), - // Full replacement for google + // Add custom model for google (merged with built-ins) google: providerConfig( "https://google-proxy.example.com/v1", [{ id: "gemini-custom" }], @@ -140,10 +140,10 @@ describe("ModelRegistry", () => { expect(anthropicModels.length).toBeGreaterThan(1); expect(anthropicModels[0].baseUrl).toBe("https://anthropic-proxy.example.com/v1"); - // Google: single custom model + // Google: built-ins plus custom model const googleModels = getModelsForProvider(registry, "google"); - expect(googleModels).toHaveLength(1); - expect(googleModels[0].id).toBe("gemini-custom"); + expect(googleModels.length).toBeGreaterThan(1); + expect(googleModels.some((m) => m.id === "gemini-custom")).toBe(true); }); test("refresh() picks up baseUrl override changes", () => { @@ -164,8 +164,8 @@ describe("ModelRegistry", () => { }); }); - describe("provider replacement (with custom models)", () => { - test("custom provider with same name as built-in replaces built-in models", () => { + describe("custom models merge behavior", () => { + test("custom provider with same name as built-in merges with built-in models", () => { writeModelsJson({ anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]), }); @@ -173,9 +173,26 @@ describe("ModelRegistry", () => { const registry = new ModelRegistry(authStorage, modelsJsonPath); const anthropicModels = getModelsForProvider(registry, "anthropic"); - expect(anthropicModels).toHaveLength(1); - expect(anthropicModels[0].id).toBe("claude-custom"); - expect(anthropicModels[0].baseUrl).toBe("https://my-proxy.example.com/v1"); + expect(anthropicModels.length).toBeGreaterThan(1); + expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(true); + expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true); + }); + + test("custom model with same id replaces built-in model by id", () => { + writeModelsJson({ + openrouter: providerConfig( + "https://my-proxy.example.com/v1", + [{ id: "anthropic/claude-sonnet-4" }], + "openai-completions", + ), + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + const sonnetModels = models.filter((m) => m.id === "anthropic/claude-sonnet-4"); + + expect(sonnetModels).toHaveLength(1); + expect(sonnetModels[0].baseUrl).toBe("https://my-proxy.example.com/v1"); }); test("custom provider with same name as built-in does not affect other built-in providers", () => { @@ -189,62 +206,85 @@ describe("ModelRegistry", () => { expect(getModelsForProvider(registry, "openai").length).toBeGreaterThan(0); }); - test("multiple built-in providers can be overridden", () => { + test("provider-level baseUrl applies to both built-in and custom models", () => { writeModelsJson({ - anthropic: providerConfig("https://anthropic-proxy.example.com/v1", [{ id: "claude-proxy" }]), - google: providerConfig( - "https://google-proxy.example.com/v1", - [{ id: "gemini-proxy" }], - "google-generative-ai", - ), + anthropic: providerConfig("https://merged-proxy.example.com/v1", [{ id: "claude-custom" }]), }); const registry = new ModelRegistry(authStorage, modelsJsonPath); const anthropicModels = getModelsForProvider(registry, "anthropic"); - const googleModels = getModelsForProvider(registry, "google"); - expect(anthropicModels).toHaveLength(1); - expect(anthropicModels[0].id).toBe("claude-proxy"); - expect(anthropicModels[0].baseUrl).toBe("https://anthropic-proxy.example.com/v1"); - - expect(googleModels).toHaveLength(1); - expect(googleModels[0].id).toBe("gemini-proxy"); - expect(googleModels[0].baseUrl).toBe("https://google-proxy.example.com/v1"); + for (const model of anthropicModels) { + expect(model.baseUrl).toBe("https://merged-proxy.example.com/v1"); + } }); - test("refresh() reloads overrides from disk", () => { + test("modelOverrides still apply when provider also defines 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: "Overridden Built-in Sonnet", + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + + expect(models.some((m) => m.id === "custom/openrouter-model")).toBe(true); + expect( + models.some((m) => m.id === "anthropic/claude-sonnet-4" && m.name === "Overridden Built-in Sonnet"), + ).toBe(true); + }); + + test("refresh() reloads merged custom models from disk", () => { writeModelsJson({ - anthropic: providerConfig("https://first-proxy.example.com/v1", [{ id: "claude-first" }]), + anthropic: providerConfig("https://first-proxy.example.com/v1", [{ id: "claude-custom" }]), }); const registry = new ModelRegistry(authStorage, modelsJsonPath); - - expect(getModelsForProvider(registry, "anthropic")[0].id).toBe("claude-first"); + expect(getModelsForProvider(registry, "anthropic").some((m) => m.id === "claude-custom")).toBe(true); // Update and refresh writeModelsJson({ - anthropic: providerConfig("https://second-proxy.example.com/v1", [{ id: "claude-second" }]), + anthropic: providerConfig("https://second-proxy.example.com/v1", [{ id: "claude-custom-2" }]), }); registry.refresh(); const anthropicModels = getModelsForProvider(registry, "anthropic"); - expect(anthropicModels[0].id).toBe("claude-second"); - expect(anthropicModels[0].baseUrl).toBe("https://second-proxy.example.com/v1"); + expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false); + expect(anthropicModels.some((m) => m.id === "claude-custom-2")).toBe(true); + expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true); }); - test("removing override from models.json restores built-in provider", () => { + test("removing custom models from models.json keeps built-in provider models", () => { writeModelsJson({ anthropic: providerConfig("https://proxy.example.com/v1", [{ id: "claude-custom" }]), }); const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect(getModelsForProvider(registry, "anthropic").some((m) => m.id === "claude-custom")).toBe(true); - expect(getModelsForProvider(registry, "anthropic")).toHaveLength(1); - - // Remove override and refresh + // Remove custom models and refresh writeModelsJson({}); registry.refresh(); const anthropicModels = getModelsForProvider(registry, "anthropic"); - expect(anthropicModels.length).toBeGreaterThan(1); + expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false); expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true); }); }); @@ -367,40 +407,6 @@ 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: {