From bd646eece3738752856014805a6ceb298134d620 Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Fri, 6 Feb 2026 15:27:04 +0000 Subject: [PATCH 1/7] feat(coding-agent): support per-model overrides in models.json Add modelOverrides field to provider config that allows customizing individual built-in models without replacing the entire provider. Example: { "providers": { "openrouter": { "modelOverrides": { "anthropic/claude-sonnet-4": { "compat": { "openRouterRouting": { "only": ["amazon-bedrock"] } } } } } } } Overrides are deep-merged with built-in model definitions. Supports: - name, reasoning, input, contextWindow, maxTokens - Partial cost overrides (e.g. only change input cost) - headers (merged with existing) - compat settings (merged with existing) Works alongside baseUrl overrides on the same provider. closes #1062 --- .../coding-agent/src/core/model-registry.ts | 123 +++++++-- .../coding-agent/test/model-registry.test.ts | 234 ++++++++++++++++++ 2 files changed, 339 insertions(+), 18 deletions(-) diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index f2f31859..6f9cb383 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -81,6 +81,27 @@ const ModelDefinitionSchema = Type.Object({ compat: Type.Optional(OpenAICompatSchema), }); +// Schema for per-model overrides (all fields optional, merged with built-in model) +const ModelOverrideSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1 })), + reasoning: Type.Optional(Type.Boolean()), + input: Type.Optional(Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")]))), + cost: Type.Optional( + Type.Object({ + input: Type.Optional(Type.Number()), + output: Type.Optional(Type.Number()), + cacheRead: Type.Optional(Type.Number()), + cacheWrite: Type.Optional(Type.Number()), + }), + ), + contextWindow: Type.Optional(Type.Number()), + maxTokens: Type.Optional(Type.Number()), + headers: Type.Optional(Type.Record(Type.String(), Type.String())), + compat: Type.Optional(OpenAICompatSchema), +}); + +type ModelOverride = Static; + const ProviderConfigSchema = Type.Object({ baseUrl: Type.Optional(Type.String({ minLength: 1 })), apiKey: Type.Optional(Type.String({ minLength: 1 })), @@ -88,6 +109,7 @@ const ProviderConfigSchema = Type.Object({ headers: Type.Optional(Type.Record(Type.String(), Type.String())), authHeader: Type.Optional(Type.Boolean()), models: Type.Optional(Type.Array(ModelDefinitionSchema)), + modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)), }); const ModelsConfigSchema = Type.Object({ @@ -110,11 +132,51 @@ interface CustomModelsResult { replacedProviders: Set; /** Providers with only baseUrl/headers override (no custom models) */ overrides: Map; + /** Per-model overrides: provider -> modelId -> override */ + modelOverrides: Map>; error: string | undefined; } function emptyCustomModelsResult(error?: string): CustomModelsResult { - return { models: [], replacedProviders: new Set(), overrides: new Map(), error }; + return { models: [], replacedProviders: new Set(), overrides: new Map(), modelOverrides: new Map(), error }; +} + +/** + * Deep merge a model override into a model. + * Handles nested objects (cost, compat) by merging rather than replacing. + */ +function applyModelOverride(model: Model, override: ModelOverride): Model { + const result = { ...model }; + + // Simple field overrides + if (override.name !== undefined) result.name = override.name; + if (override.reasoning !== undefined) result.reasoning = override.reasoning; + if (override.input !== undefined) result.input = override.input as ("text" | "image")[]; + if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow; + if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens; + + // Merge cost (partial override) + if (override.cost) { + result.cost = { + input: override.cost.input ?? model.cost.input, + output: override.cost.output ?? model.cost.output, + cacheRead: override.cost.cacheRead ?? model.cost.cacheRead, + cacheWrite: override.cost.cacheWrite ?? model.cost.cacheWrite, + }; + } + + // Merge headers + if (override.headers) { + const resolvedHeaders = resolveHeaders(override.headers); + result.headers = resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers; + } + + // Deep merge compat + if (override.compat) { + result.compat = model.compat ? { ...model.compat, ...override.compat } : override.compat; + } + + return result; } /** Clear the config value command cache. Exported for testing. */ @@ -172,6 +234,7 @@ export class ModelRegistry { models: customModels, replacedProviders, overrides, + modelOverrides, error, } = this.modelsJsonPath ? this.loadCustomModels(this.modelsJsonPath) : emptyCustomModelsResult(); @@ -180,7 +243,7 @@ export class ModelRegistry { // Keep built-in models even if custom models failed to load } - const builtInModels = this.loadBuiltInModels(replacedProviders, overrides); + const builtInModels = this.loadBuiltInModels(replacedProviders, overrides, modelOverrides); let combined = [...builtInModels, ...customModels]; // Let OAuth providers modify their models (e.g., update baseUrl) @@ -195,21 +258,39 @@ export class ModelRegistry { } /** Load built-in models, skipping replaced providers and applying overrides */ - private loadBuiltInModels(replacedProviders: Set, overrides: Map): Model[] { + 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 override = overrides.get(provider); - if (!override) return models; + const providerOverride = overrides.get(provider); + const perModelOverrides = modelOverrides.get(provider); - // Apply baseUrl/headers override to all models of this provider - const resolvedHeaders = resolveHeaders(override.headers); - return models.map((m) => ({ - ...m, - baseUrl: override.baseUrl ?? m.baseUrl, - headers: resolvedHeaders ? { ...m.headers, ...resolvedHeaders } : m.headers, - })); + 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 per-model override + const modelOverride = perModelOverrides?.get(m.id); + if (modelOverride) { + model = applyModelOverride(model, modelOverride); + } + + return model; + }); }); } @@ -238,6 +319,7 @@ export class ModelRegistry { // 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) { @@ -255,9 +337,14 @@ export class ModelRegistry { this.customProviderApiKeys.set(providerName, providerConfig.apiKey); } } + + // Collect per-model overrides (works with both full replacement and override-only) + if (providerConfig.modelOverrides) { + modelOverrides.set(providerName, new Map(Object.entries(providerConfig.modelOverrides))); + } } - return { models: this.parseModels(config), replacedProviders, overrides, error: undefined }; + return { models: this.parseModels(config), replacedProviders, overrides, modelOverrides, error: undefined }; } catch (error) { if (error instanceof SyntaxError) { return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`); @@ -272,13 +359,13 @@ export class ModelRegistry { for (const [providerName, providerConfig] of Object.entries(config.providers)) { const hasProviderApi = !!providerConfig.api; const models = providerConfig.models ?? []; + const hasModelOverrides = + providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0; if (models.length === 0) { - // Override-only config: just needs baseUrl (to override built-in) - if (!providerConfig.baseUrl) { - throw new Error( - `Provider ${providerName}: must specify either "baseUrl" (for override) or "models" (for replacement).`, - ); + // Override-only config: needs baseUrl OR modelOverrides (or both) + if (!providerConfig.baseUrl && !hasModelOverrides) { + throw new Error(`Provider ${providerName}: must specify "baseUrl", "modelOverrides", or "models".`); } } else { // Full replacement: needs baseUrl and apiKey diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index cc5f4dee..f4230176 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -248,6 +248,240 @@ describe("ModelRegistry", () => { }); }); + describe("modelOverrides (per-model customization)", () => { + test("model override applies to a single built-in model", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + name: "Custom Sonnet Name", + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + + const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); + expect(sonnet?.name).toBe("Custom Sonnet Name"); + + // Other models should be unchanged + const opus = models.find((m) => m.id === "anthropic/claude-opus-4"); + expect(opus?.name).not.toBe("Custom Sonnet Name"); + }); + + test("model override with compat.openRouterRouting", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + compat: { + openRouterRouting: { only: ["amazon-bedrock"] }, + }, + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + 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"] }); + }); + + test("model override deep merges compat settings", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + compat: { + openRouterRouting: { order: ["anthropic", "together"] }, + }, + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + 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"] }); + }); + + test("multiple model overrides on same provider", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + compat: { openRouterRouting: { only: ["amazon-bedrock"] } }, + }, + "anthropic/claude-opus-4": { + compat: { openRouterRouting: { only: ["anthropic"] } }, + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + + 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"] }); + }); + + test("model override combined with baseUrl override", () => { + writeRawModelsJson({ + openrouter: { + baseUrl: "https://my-proxy.example.com/v1", + modelOverrides: { + "anthropic/claude-sonnet-4": { + name: "Proxied Sonnet", + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); + + // Both overrides should apply + expect(sonnet?.baseUrl).toBe("https://my-proxy.example.com/v1"); + expect(sonnet?.name).toBe("Proxied Sonnet"); + + // Other models should have the baseUrl but not the name override + const opus = models.find((m) => m.id === "anthropic/claude-opus-4"); + expect(opus?.baseUrl).toBe("https://my-proxy.example.com/v1"); + expect(opus?.name).not.toBe("Proxied Sonnet"); + }); + + test("model override for non-existent model ID is ignored", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "nonexistent/model-id": { + name: "This should not appear", + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + + // Should not create a new model + expect(models.find((m) => m.id === "nonexistent/model-id")).toBeUndefined(); + // Should not crash or show error + expect(registry.getError()).toBeUndefined(); + }); + + test("model override can change cost fields partially", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + cost: { input: 99 }, + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); + + // Input cost should be overridden + expect(sonnet?.cost.input).toBe(99); + // Other cost fields should be preserved from built-in + expect(sonnet?.cost.output).toBeGreaterThan(0); + }); + + test("model override can add headers", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + headers: { "X-Custom-Model-Header": "value" }, + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + const sonnet = models.find((m) => m.id === "anthropic/claude-sonnet-4"); + + expect(sonnet?.headers?.["X-Custom-Model-Header"]).toBe("value"); + }); + + test("refresh() picks up model override changes", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + name: "First Name", + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + expect( + getModelsForProvider(registry, "openrouter").find((m) => m.id === "anthropic/claude-sonnet-4")?.name, + ).toBe("First Name"); + + // Update and refresh + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + name: "Second Name", + }, + }, + }, + }); + registry.refresh(); + + expect( + getModelsForProvider(registry, "openrouter").find((m) => m.id === "anthropic/claude-sonnet-4")?.name, + ).toBe("Second Name"); + }); + + test("removing model override restores built-in values", () => { + writeRawModelsJson({ + openrouter: { + modelOverrides: { + "anthropic/claude-sonnet-4": { + name: "Custom Name", + }, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const customName = getModelsForProvider(registry, "openrouter").find( + (m) => m.id === "anthropic/claude-sonnet-4", + )?.name; + expect(customName).toBe("Custom Name"); + + // Remove override and refresh + writeRawModelsJson({}); + registry.refresh(); + + const restoredName = getModelsForProvider(registry, "openrouter").find( + (m) => m.id === "anthropic/claude-sonnet-4", + )?.name; + expect(restoredName).not.toBe("Custom Name"); + }); + }); + describe("API key resolution", () => { /** Create provider config with custom apiKey */ function providerWithApiKey(apiKey: string) { From 6f897c36734a75119d97878c3757ea84e51aca21 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 6 Feb 2026 18:53:54 +0100 Subject: [PATCH 2/7] 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: { From dc22b0efd4862fa99b2e59e5f7030cd401e3b75d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 6 Feb 2026 19:01:35 +0100 Subject: [PATCH 3/7] chore(prompts): require independent issue analysis --- .pi/prompts/is.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.pi/prompts/is.md b/.pi/prompts/is.md index f57561f6..28053dfe 100644 --- a/.pi/prompts/is.md +++ b/.pi/prompts/is.md @@ -6,14 +6,16 @@ Analyze GitHub issue(s): $ARGUMENTS For each issue: 1. Read the issue in full, including all comments and linked issues/PRs. +2. Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path. -2. **For bugs**: +3. **For bugs**: - Ignore any root cause analysis in the issue (likely wrong) - Read all related code files in full (no truncation) - Trace the code path and identify the actual root cause - Propose a fix -3. **For feature requests**: +4. **For feature requests**: + - Do not trust implementation proposals in the issue without verification - Read all related code files in full (no truncation) - Propose the most concise implementation approach - List affected files and changes needed From 98efcb30a9d1148b7421531f17fb110e5cc4a34f Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 6 Feb 2026 19:01:42 +0100 Subject: [PATCH 4/7] fix(coding-agent): handle compromised auth lock without crashing closes #1322 --- .../coding-agent/src/core/auth-storage.ts | 17 ++++++ .../coding-agent/test/auth-storage.test.ts | 54 ++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 21b62153..c4dbaa73 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -196,6 +196,13 @@ export class AuthStorage { } let release: (() => Promise) | undefined; + let lockCompromised = false; + let lockCompromisedError: Error | undefined; + const throwIfLockCompromised = () => { + if (lockCompromised) { + throw lockCompromisedError ?? new Error("OAuth refresh lock was compromised"); + } + }; try { // Acquire exclusive lock with retry and timeout @@ -209,8 +216,14 @@ export class AuthStorage { randomize: true, }, stale: 30000, // Consider lock stale after 30 seconds + onCompromised: (err) => { + lockCompromised = true; + lockCompromisedError = err; + }, }); + throwIfLockCompromised(); + // Re-read file after acquiring lock - another instance may have refreshed this.reload(); @@ -223,6 +236,7 @@ export class AuthStorage { // (another instance may have already refreshed it) if (Date.now() < cred.expires) { // Token is now valid - another instance refreshed it + throwIfLockCompromised(); const apiKey = provider.getApiKey(cred); return { apiKey, newCredentials: cred }; } @@ -237,11 +251,14 @@ export class AuthStorage { const result = await getOAuthApiKey(providerId, oauthCreds); if (result) { + throwIfLockCompromised(); this.data[providerId] = { type: "oauth", ...result.newCredentials }; this.save(); + throwIfLockCompromised(); return result; } + throwIfLockCompromised(); return null; } finally { // Always release the lock diff --git a/packages/coding-agent/test/auth-storage.test.ts b/packages/coding-agent/test/auth-storage.test.ts index 0fdab3c8..0b395538 100644 --- a/packages/coding-agent/test/auth-storage.test.ts +++ b/packages/coding-agent/test/auth-storage.test.ts @@ -1,7 +1,9 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { registerOAuthProvider } from "@mariozechner/pi-ai"; +import lockfile from "proper-lockfile"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { AuthStorage } from "../src/core/auth-storage.js"; import { clearConfigValueCache } from "../src/core/resolve-config-value.js"; @@ -21,6 +23,7 @@ describe("AuthStorage", () => { rmSync(tempDir, { recursive: true }); } clearConfigValueCache(); + vi.restoreAllMocks(); }); function writeAuthJson(data: Record) { @@ -287,6 +290,55 @@ describe("AuthStorage", () => { }); }); + describe("oauth lock compromise handling", () => { + test("returns undefined on compromised lock and allows a later retry", async () => { + const providerId = `test-oauth-provider-${Date.now()}-${Math.random().toString(36).slice(2)}`; + registerOAuthProvider({ + id: providerId, + name: "Test OAuth Provider", + async login() { + throw new Error("Not used in this test"); + }, + async refreshToken(credentials) { + return { + ...credentials, + access: "refreshed-access-token", + expires: Date.now() + 60_000, + }; + }, + getApiKey(credentials) { + return `Bearer ${credentials.access}`; + }, + }); + + writeAuthJson({ + [providerId]: { + type: "oauth", + refresh: "refresh-token", + access: "expired-access-token", + expires: Date.now() - 10_000, + }, + }); + + authStorage = new AuthStorage(authJsonPath); + + const realLock = lockfile.lock.bind(lockfile); + const lockSpy = vi.spyOn(lockfile, "lock"); + lockSpy.mockImplementationOnce(async (file, options) => { + options?.onCompromised?.(new Error("Unable to update lock within the stale threshold")); + return realLock(file, options); + }); + + const firstTry = await authStorage.getApiKey(providerId); + expect(firstTry).toBeUndefined(); + + lockSpy.mockRestore(); + + const secondTry = await authStorage.getApiKey(providerId); + expect(secondTry).toBe("Bearer refreshed-access-token"); + }); + }); + describe("runtime overrides", () => { test("runtime override takes priority over auth.json", async () => { writeAuthJson({ From c35be6605c13fd9dbd8f496bc62493fb1640f981 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 6 Feb 2026 19:11:38 +0100 Subject: [PATCH 5/7] 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: { From e9749f30673940215376028d4f08f0b174bf09f0 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 6 Feb 2026 19:14:27 +0100 Subject: [PATCH 6/7] docs(coding-agent): add unreleased new features summary --- packages/coding-agent/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 17508c41..a9cbb8ba 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### New Features + +- Per-model overrides in `models.json` via `modelOverrides`, allowing customization of built-in provider models without replacing provider model lists. See [docs/models.md#per-model-overrides](docs/models.md#per-model-overrides). +- `models.json` provider `models` now merge with built-in models by `id`, so custom models can be added or replace matching built-ins without full provider replacement. See [docs/models.md#overriding-built-in-providers](docs/models.md#overriding-built-in-providers). +- Bedrock proxy support for unauthenticated endpoints via `AWS_BEDROCK_SKIP_AUTH` and `AWS_BEDROCK_FORCE_HTTP1`. See [docs/providers.md](docs/providers.md). + ### 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`. From 6b2d8a77b2c7b525c9af20a6b5c26e5fcb8b59a4 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 6 Feb 2026 19:16:35 +0100 Subject: [PATCH 7/7] (chore) Changelog update --- packages/ai/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 14e711f7..23b21fa9 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -9,6 +9,8 @@ ### Fixed - Set OpenAI Responses API requests to `store: false` by default to avoid server-side history logging ([#1308](https://github.com/badlogic/pi-mono/issues/1308)) +- 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 `AWS_BEDROCK_SKIP_AUTH` environment detection to avoid `process` access in non-Node.js environments ## [0.52.6] - 2026-02-05