mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 09:02:08 +00:00
feat(coding-agent): merge custom models with built-ins by id
This commit is contained in:
parent
ddd5a65c7e
commit
76a6a74517
4 changed files with 147 additions and 122 deletions
|
|
@ -2,15 +2,25 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
||||||
|
|
||||||
- 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 `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
|
||||||
|
|
||||||
- Fixed extra spacing between thinking-only assistant content and subsequent tool execution blocks when assistant messages contain no text
|
- 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 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 `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
|
## [0.52.6] - 2026-02-05
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
```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
|
## Per-model Overrides
|
||||||
|
|
||||||
Use `modelOverrides` to customize specific built-in models without replacing the provider's full model list.
|
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`.
|
`modelOverrides` supports these fields per model: `name`, `reasoning`, `input`, `cost` (partial), `contextWindow`, `maxTokens`, `headers`, `compat`.
|
||||||
|
|
||||||
Behavior notes:
|
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.
|
- Unknown model IDs are ignored.
|
||||||
- You can combine provider-level `baseUrl`/`headers` with `modelOverrides`.
|
- 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
|
## OpenAI Compatibility
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,7 @@ interface ProviderOverride {
|
||||||
/** Result of loading custom models from models.json */
|
/** Result of loading custom models from models.json */
|
||||||
interface CustomModelsResult {
|
interface CustomModelsResult {
|
||||||
models: Model<Api>[];
|
models: Model<Api>[];
|
||||||
/** Providers with custom models (full replacement) */
|
/** Providers with baseUrl/headers/apiKey overrides for built-in models */
|
||||||
replacedProviders: Set<string>;
|
|
||||||
/** Providers with only baseUrl/headers override (no custom models) */
|
|
||||||
overrides: Map<string, ProviderOverride>;
|
overrides: Map<string, ProviderOverride>;
|
||||||
/** Per-model overrides: provider -> modelId -> override */
|
/** Per-model overrides: provider -> modelId -> override */
|
||||||
modelOverrides: Map<string, Map<string, ModelOverride>>;
|
modelOverrides: Map<string, Map<string, ModelOverride>>;
|
||||||
|
|
@ -140,7 +138,7 @@ interface CustomModelsResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyCustomModelsResult(error?: string): 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(
|
function mergeCompat(
|
||||||
|
|
@ -260,10 +258,9 @@ export class ModelRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadModels(): void {
|
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 {
|
const {
|
||||||
models: customModels,
|
models: customModels,
|
||||||
replacedProviders,
|
|
||||||
overrides,
|
overrides,
|
||||||
modelOverrides,
|
modelOverrides,
|
||||||
error,
|
error,
|
||||||
|
|
@ -274,8 +271,8 @@ export class ModelRegistry {
|
||||||
// Keep built-in models even if custom models failed to load
|
// Keep built-in models even if custom models failed to load
|
||||||
}
|
}
|
||||||
|
|
||||||
const builtInModels = this.loadBuiltInModels(replacedProviders, overrides, modelOverrides);
|
const builtInModels = this.loadBuiltInModels(overrides, modelOverrides);
|
||||||
let combined = [...builtInModels, ...customModels];
|
let combined = this.mergeCustomModels(builtInModels, customModels);
|
||||||
|
|
||||||
// Let OAuth providers modify their models (e.g., update baseUrl)
|
// Let OAuth providers modify their models (e.g., update baseUrl)
|
||||||
for (const oauthProvider of this.authStorage.getOAuthProviders()) {
|
for (const oauthProvider of this.authStorage.getOAuthProviders()) {
|
||||||
|
|
@ -288,41 +285,52 @@ export class ModelRegistry {
|
||||||
this.models = combined;
|
this.models = combined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load built-in models, skipping replaced providers and applying overrides */
|
/** Load built-in models and apply provider/model overrides */
|
||||||
private loadBuiltInModels(
|
private loadBuiltInModels(
|
||||||
replacedProviders: Set<string>,
|
|
||||||
overrides: Map<string, ProviderOverride>,
|
overrides: Map<string, ProviderOverride>,
|
||||||
modelOverrides: Map<string, Map<string, ModelOverride>>,
|
modelOverrides: Map<string, Map<string, ModelOverride>>,
|
||||||
): Model<Api>[] {
|
): Model<Api>[] {
|
||||||
return getProviders()
|
return getProviders().flatMap((provider) => {
|
||||||
.filter((provider) => !replacedProviders.has(provider))
|
const models = getModels(provider as KnownProvider) as Model<Api>[];
|
||||||
.flatMap((provider) => {
|
const providerOverride = overrides.get(provider);
|
||||||
const models = getModels(provider as KnownProvider) as Model<Api>[];
|
const perModelOverrides = modelOverrides.get(provider);
|
||||||
const providerOverride = overrides.get(provider);
|
|
||||||
const perModelOverrides = modelOverrides.get(provider);
|
|
||||||
|
|
||||||
return models.map((m) => {
|
return models.map((m) => {
|
||||||
let model = m;
|
let model = m;
|
||||||
|
|
||||||
// Apply provider-level baseUrl/headers override
|
// Apply provider-level baseUrl/headers override
|
||||||
if (providerOverride) {
|
if (providerOverride) {
|
||||||
const resolvedHeaders = resolveHeaders(providerOverride.headers);
|
const resolvedHeaders = resolveHeaders(providerOverride.headers);
|
||||||
model = {
|
model = {
|
||||||
...model,
|
...model,
|
||||||
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
|
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
|
||||||
headers: resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers,
|
headers: resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply per-model override
|
// Apply per-model override
|
||||||
const modelOverride = perModelOverrides?.get(m.id);
|
const modelOverride = perModelOverrides?.get(m.id);
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
model = applyModelOverride(model, 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<Api>[], customModels: Model<Api>[]): Model<Api>[] {
|
||||||
|
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 {
|
private loadCustomModels(modelsJsonPath: string): CustomModelsResult {
|
||||||
|
|
@ -347,35 +355,30 @@ export class ModelRegistry {
|
||||||
// Additional validation
|
// Additional validation
|
||||||
this.validateConfig(config);
|
this.validateConfig(config);
|
||||||
|
|
||||||
// Separate providers into "full replacement" (has models) vs "override-only" (no models)
|
|
||||||
const replacedProviders = new Set<string>();
|
|
||||||
const overrides = new Map<string, ProviderOverride>();
|
const overrides = new Map<string, ProviderOverride>();
|
||||||
const modelOverrides = new Map<string, Map<string, ModelOverride>>();
|
const modelOverrides = new Map<string, Map<string, ModelOverride>>();
|
||||||
|
|
||||||
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
||||||
if (providerConfig.models && providerConfig.models.length > 0) {
|
// Apply provider-level baseUrl/headers/apiKey override to built-in models when configured.
|
||||||
// Has custom models -> full replacement
|
if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey) {
|
||||||
replacedProviders.add(providerName);
|
|
||||||
} else {
|
|
||||||
// No models -> just override baseUrl/headers on built-in
|
|
||||||
overrides.set(providerName, {
|
overrides.set(providerName, {
|
||||||
baseUrl: providerConfig.baseUrl,
|
baseUrl: providerConfig.baseUrl,
|
||||||
headers: providerConfig.headers,
|
headers: providerConfig.headers,
|
||||||
apiKey: providerConfig.apiKey,
|
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) {
|
if (providerConfig.modelOverrides) {
|
||||||
modelOverrides.set(providerName, new Map(Object.entries(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) {
|
} catch (error) {
|
||||||
if (error instanceof SyntaxError) {
|
if (error instanceof SyntaxError) {
|
||||||
return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`);
|
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".`);
|
throw new Error(`Provider ${providerName}: must specify "baseUrl", "modelOverrides", or "models".`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Full replacement: needs baseUrl and apiKey
|
// Custom models are merged into provider models and require endpoint + auth.
|
||||||
if (!providerConfig.baseUrl) {
|
if (!providerConfig.baseUrl) {
|
||||||
throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
|
throw new Error(`Provider ${providerName}: "baseUrl" is required when defining custom models.`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,11 +121,11 @@ describe("ModelRegistry", () => {
|
||||||
expect(googleModels[0].baseUrl).not.toBe("https://my-proxy.example.com/v1");
|
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({
|
writeRawModelsJson({
|
||||||
// baseUrl-only for anthropic
|
// baseUrl-only for anthropic
|
||||||
anthropic: overrideConfig("https://anthropic-proxy.example.com/v1"),
|
anthropic: overrideConfig("https://anthropic-proxy.example.com/v1"),
|
||||||
// Full replacement for google
|
// Add custom model for google (merged with built-ins)
|
||||||
google: providerConfig(
|
google: providerConfig(
|
||||||
"https://google-proxy.example.com/v1",
|
"https://google-proxy.example.com/v1",
|
||||||
[{ id: "gemini-custom" }],
|
[{ id: "gemini-custom" }],
|
||||||
|
|
@ -140,10 +140,10 @@ describe("ModelRegistry", () => {
|
||||||
expect(anthropicModels.length).toBeGreaterThan(1);
|
expect(anthropicModels.length).toBeGreaterThan(1);
|
||||||
expect(anthropicModels[0].baseUrl).toBe("https://anthropic-proxy.example.com/v1");
|
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");
|
const googleModels = getModelsForProvider(registry, "google");
|
||||||
expect(googleModels).toHaveLength(1);
|
expect(googleModels.length).toBeGreaterThan(1);
|
||||||
expect(googleModels[0].id).toBe("gemini-custom");
|
expect(googleModels.some((m) => m.id === "gemini-custom")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("refresh() picks up baseUrl override changes", () => {
|
test("refresh() picks up baseUrl override changes", () => {
|
||||||
|
|
@ -164,8 +164,8 @@ describe("ModelRegistry", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("provider replacement (with custom models)", () => {
|
describe("custom models merge behavior", () => {
|
||||||
test("custom provider with same name as built-in replaces built-in models", () => {
|
test("custom provider with same name as built-in merges with built-in models", () => {
|
||||||
writeModelsJson({
|
writeModelsJson({
|
||||||
anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]),
|
anthropic: providerConfig("https://my-proxy.example.com/v1", [{ id: "claude-custom" }]),
|
||||||
});
|
});
|
||||||
|
|
@ -173,9 +173,26 @@ describe("ModelRegistry", () => {
|
||||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||||
|
|
||||||
expect(anthropicModels).toHaveLength(1);
|
expect(anthropicModels.length).toBeGreaterThan(1);
|
||||||
expect(anthropicModels[0].id).toBe("claude-custom");
|
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(true);
|
||||||
expect(anthropicModels[0].baseUrl).toBe("https://my-proxy.example.com/v1");
|
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", () => {
|
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);
|
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({
|
writeModelsJson({
|
||||||
anthropic: providerConfig("https://anthropic-proxy.example.com/v1", [{ id: "claude-proxy" }]),
|
anthropic: providerConfig("https://merged-proxy.example.com/v1", [{ id: "claude-custom" }]),
|
||||||
google: providerConfig(
|
|
||||||
"https://google-proxy.example.com/v1",
|
|
||||||
[{ id: "gemini-proxy" }],
|
|
||||||
"google-generative-ai",
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||||
const googleModels = getModelsForProvider(registry, "google");
|
|
||||||
|
|
||||||
expect(anthropicModels).toHaveLength(1);
|
for (const model of anthropicModels) {
|
||||||
expect(anthropicModels[0].id).toBe("claude-proxy");
|
expect(model.baseUrl).toBe("https://merged-proxy.example.com/v1");
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
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);
|
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||||
|
expect(getModelsForProvider(registry, "anthropic").some((m) => m.id === "claude-custom")).toBe(true);
|
||||||
expect(getModelsForProvider(registry, "anthropic")[0].id).toBe("claude-first");
|
|
||||||
|
|
||||||
// Update and refresh
|
// Update and refresh
|
||||||
writeModelsJson({
|
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();
|
registry.refresh();
|
||||||
|
|
||||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
||||||
expect(anthropicModels[0].id).toBe("claude-second");
|
expect(anthropicModels.some((m) => m.id === "claude-custom")).toBe(false);
|
||||||
expect(anthropicModels[0].baseUrl).toBe("https://second-proxy.example.com/v1");
|
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({
|
writeModelsJson({
|
||||||
anthropic: providerConfig("https://proxy.example.com/v1", [{ id: "claude-custom" }]),
|
anthropic: providerConfig("https://proxy.example.com/v1", [{ id: "claude-custom" }]),
|
||||||
});
|
});
|
||||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
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 custom models and refresh
|
||||||
|
|
||||||
// Remove override and refresh
|
|
||||||
writeModelsJson({});
|
writeModelsJson({});
|
||||||
registry.refresh();
|
registry.refresh();
|
||||||
|
|
||||||
const anthropicModels = getModelsForProvider(registry, "anthropic");
|
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);
|
expect(anthropicModels.some((m) => m.id.includes("claude"))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -367,40 +407,6 @@ describe("ModelRegistry", () => {
|
||||||
expect(opus?.name).not.toBe("Proxied Sonnet");
|
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", () => {
|
test("model override for non-existent model ID is ignored", () => {
|
||||||
writeRawModelsJson({
|
writeRawModelsJson({
|
||||||
openrouter: {
|
openrouter: {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue