mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
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
This commit is contained in:
parent
f5b9eeb514
commit
2cd55b2d35
2 changed files with 339 additions and 18 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue