From 2cd55b2d35bd7061568fc927cde68d3031f829f1 Mon Sep 17 00:00:00 2001 From: Charles Cooper Date: Fri, 6 Feb 2026 15:27:04 +0000 Subject: [PATCH] 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) {