Fix custom model precedence across load and refresh
This commit is contained in:
@@ -356,7 +356,7 @@ export interface ProviderDiscoveryState {
|
||||
|
||||
/** Result of loading custom models from models.json */
|
||||
interface CustomModelsResult {
|
||||
models?: Model<Api>[];
|
||||
models?: CustomModelOverlay[];
|
||||
overrides?: Map<string, ProviderOverride>;
|
||||
modelOverrides?: Map<string, Map<string, ModelOverride>>;
|
||||
keylessProviders?: Set<string>;
|
||||
@@ -551,6 +551,24 @@ interface CustomModelBuildOptions {
|
||||
useDefaults: boolean;
|
||||
}
|
||||
|
||||
type CustomModelOverlay = {
|
||||
id: string;
|
||||
provider: string;
|
||||
api: Api;
|
||||
baseUrl: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
thinking?: ThinkingConfig;
|
||||
input?: ("text" | "image")[];
|
||||
cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
headers?: Record<string, string>;
|
||||
compat?: Model<Api>["compat"];
|
||||
contextPromotionTarget?: string;
|
||||
premiumMultiplier?: number;
|
||||
};
|
||||
|
||||
function mergeCustomModelHeaders(
|
||||
providerHeaders: Record<string, string> | undefined,
|
||||
modelHeaders: Record<string, string> | undefined,
|
||||
@@ -567,6 +585,68 @@ function mergeCustomModelHeaders(
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildCustomModelOverlay(
|
||||
providerName: string,
|
||||
providerBaseUrl: string,
|
||||
providerApi: Api | undefined,
|
||||
providerHeaders: Record<string, string> | undefined,
|
||||
providerApiKey: string | undefined,
|
||||
authHeader: boolean | undefined,
|
||||
providerCompat: Model<Api>["compat"] | undefined,
|
||||
modelDef: CustomModelDefinitionLike,
|
||||
): CustomModelOverlay | undefined {
|
||||
const api = modelDef.api ?? providerApi;
|
||||
if (!api) return undefined;
|
||||
return {
|
||||
id: modelDef.id,
|
||||
provider: providerName,
|
||||
api,
|
||||
baseUrl: modelDef.baseUrl ?? providerBaseUrl,
|
||||
name: modelDef.name,
|
||||
reasoning: modelDef.reasoning,
|
||||
thinking: modelDef.thinking as ThinkingConfig | undefined,
|
||||
input: modelDef.input as ("text" | "image")[] | undefined,
|
||||
cost: modelDef.cost,
|
||||
contextWindow: modelDef.contextWindow,
|
||||
maxTokens: modelDef.maxTokens,
|
||||
headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
|
||||
compat: mergeCompat(providerCompat, modelDef.compat),
|
||||
contextPromotionTarget: modelDef.contextPromotionTarget,
|
||||
premiumMultiplier: modelDef.premiumMultiplier,
|
||||
};
|
||||
}
|
||||
|
||||
function applyStandaloneCustomModelPolicies(model: CustomModelOverlay): CustomModelOverlay {
|
||||
if (model.id !== "gpt-5.4" || model.provider === "github-copilot" || model.contextWindow !== undefined) {
|
||||
return model;
|
||||
}
|
||||
return { ...model, contextWindow: 1_000_000 };
|
||||
}
|
||||
|
||||
function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuildOptions): Model<Api> {
|
||||
const resolvedModel = options.useDefaults ? applyStandaloneCustomModelPolicies(model) : model;
|
||||
const cost =
|
||||
resolvedModel.cost ?? (options.useDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
|
||||
const input = resolvedModel.input ?? (options.useDefaults ? ["text"] : undefined);
|
||||
return enrichModelThinking({
|
||||
id: resolvedModel.id,
|
||||
name: resolvedModel.name ?? (options.useDefaults ? resolvedModel.id : undefined),
|
||||
api: resolvedModel.api,
|
||||
provider: resolvedModel.provider,
|
||||
baseUrl: resolvedModel.baseUrl,
|
||||
reasoning: resolvedModel.reasoning ?? (options.useDefaults ? false : undefined),
|
||||
thinking: resolvedModel.thinking,
|
||||
input: input as ("text" | "image")[],
|
||||
cost,
|
||||
contextWindow: resolvedModel.contextWindow ?? (options.useDefaults ? 128000 : undefined),
|
||||
maxTokens: resolvedModel.maxTokens ?? (options.useDefaults ? 16384 : undefined),
|
||||
headers: resolvedModel.headers,
|
||||
compat: resolvedModel.compat,
|
||||
contextPromotionTarget: resolvedModel.contextPromotionTarget,
|
||||
premiumMultiplier: resolvedModel.premiumMultiplier,
|
||||
} as Model<Api>);
|
||||
}
|
||||
|
||||
function buildCustomModel(
|
||||
providerName: string,
|
||||
providerBaseUrl: string,
|
||||
@@ -578,28 +658,18 @@ function buildCustomModel(
|
||||
modelDef: CustomModelDefinitionLike,
|
||||
options: CustomModelBuildOptions,
|
||||
): Model<Api> | undefined {
|
||||
const api = modelDef.api ?? providerApi;
|
||||
if (!api) return undefined;
|
||||
const withDefaults = options.useDefaults;
|
||||
const cost = modelDef.cost ?? (withDefaults ? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } : undefined);
|
||||
const input = modelDef.input ?? (withDefaults ? ["text"] : undefined);
|
||||
return enrichModelThinking({
|
||||
id: modelDef.id,
|
||||
name: modelDef.name ?? (withDefaults ? modelDef.id : undefined),
|
||||
api,
|
||||
provider: providerName,
|
||||
baseUrl: modelDef.baseUrl ?? providerBaseUrl,
|
||||
reasoning: modelDef.reasoning ?? (withDefaults ? false : undefined),
|
||||
thinking: modelDef.thinking as ThinkingConfig | undefined,
|
||||
input: input as ("text" | "image")[],
|
||||
cost,
|
||||
contextWindow: modelDef.contextWindow ?? (withDefaults ? 128000 : undefined),
|
||||
maxTokens: modelDef.maxTokens ?? (withDefaults ? 16384 : undefined),
|
||||
headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
|
||||
compat: mergeCompat(providerCompat, modelDef.compat),
|
||||
contextPromotionTarget: modelDef.contextPromotionTarget,
|
||||
premiumMultiplier: modelDef.premiumMultiplier,
|
||||
} as Model<Api>);
|
||||
const model = buildCustomModelOverlay(
|
||||
providerName,
|
||||
providerBaseUrl,
|
||||
providerApi,
|
||||
providerHeaders,
|
||||
providerApiKey,
|
||||
authHeader,
|
||||
providerCompat,
|
||||
modelDef,
|
||||
);
|
||||
if (!model) return undefined;
|
||||
return finalizeCustomModel(model, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -610,6 +680,8 @@ export class ModelRegistry {
|
||||
#customProviderApiKeys: Map<string, string> = new Map();
|
||||
#keylessProviders: Set<string> = new Set();
|
||||
#discoverableProviders: DiscoveryProviderConfig[] = [];
|
||||
#customModelOverlays: CustomModelOverlay[] = [];
|
||||
#providerOverrides: Map<string, ProviderOverride> = new Map();
|
||||
#modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
|
||||
#configError: ConfigError | undefined = undefined;
|
||||
#modelsConfigFile: ConfigFile<ModelsConfig>;
|
||||
@@ -676,6 +748,7 @@ export class ModelRegistry {
|
||||
this.#customProviderApiKeys.clear();
|
||||
this.#keylessProviders.clear();
|
||||
this.#discoverableProviders = [];
|
||||
this.#providerOverrides.clear();
|
||||
this.#modelOverrides.clear();
|
||||
this.#configError = undefined;
|
||||
this.#providerDiscoveryStates.clear();
|
||||
@@ -703,14 +776,17 @@ export class ModelRegistry {
|
||||
this.#configError = configError;
|
||||
this.#keylessProviders = keylessProviders;
|
||||
this.#discoverableProviders = discoverableProviders;
|
||||
this.#customModelOverlays = customModels;
|
||||
this.#providerOverrides = overrides;
|
||||
this.#modelOverrides = modelOverrides;
|
||||
|
||||
this.#addImplicitDiscoverableProviders(configuredProviders);
|
||||
const builtInModels = this.#loadBuiltInModels(overrides, modelOverrides);
|
||||
const cachedDiscoveries = this.#loadCachedDiscoverableModels();
|
||||
const combined = this.#mergeCustomModels(builtInModels, [...customModels, ...cachedDiscoveries]);
|
||||
const builtInModels = this.#applyHardcodedModelPolicies(this.#loadBuiltInModels(overrides, modelOverrides));
|
||||
const cachedDiscoveries = this.#applyHardcodedModelPolicies(this.#loadCachedDiscoverableModels());
|
||||
const resolvedDefaults = this.#mergeResolvedModels(builtInModels, cachedDiscoveries);
|
||||
const combined = this.#mergeCustomModels(resolvedDefaults, this.#customModelOverlays);
|
||||
|
||||
this.#models = this.#applyHardcodedModelPolicies(combined);
|
||||
this.#models = this.#applyModelOverrides(combined, this.#modelOverrides);
|
||||
}
|
||||
|
||||
/** Load built-in models, applying provider and per-model overrides */
|
||||
@@ -742,15 +818,50 @@ export class ModelRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
#mergeResolvedModels(baseModels: Model<Api>[], replacementModels: Model<Api>[]): Model<Api>[] {
|
||||
const merged = [...baseModels];
|
||||
for (const replacementModel of replacementModels) {
|
||||
const existingIndex = merged.findIndex(
|
||||
m => m.provider === replacementModel.provider && m.id === replacementModel.id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
merged[existingIndex] = replacementModel;
|
||||
} else {
|
||||
merged.push(replacementModel);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Merge custom models with built-in, replacing by provider+id match */
|
||||
#mergeCustomModels(builtInModels: Model<Api>[], customModels: Model<Api>[]): Model<Api>[] {
|
||||
#mergeCustomModels(builtInModels: Model<Api>[], customModels: CustomModelOverlay[]): 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;
|
||||
merged[existingIndex] = enrichModelThinking({
|
||||
...merged[existingIndex],
|
||||
id: customModel.id,
|
||||
provider: customModel.provider,
|
||||
api: customModel.api,
|
||||
baseUrl: customModel.baseUrl,
|
||||
name: customModel.name ?? merged[existingIndex].name,
|
||||
reasoning: customModel.reasoning ?? merged[existingIndex].reasoning,
|
||||
thinking: customModel.thinking ?? merged[existingIndex].thinking,
|
||||
input: customModel.input ?? merged[existingIndex].input,
|
||||
cost: customModel.cost ?? merged[existingIndex].cost,
|
||||
contextWindow: customModel.contextWindow ?? merged[existingIndex].contextWindow,
|
||||
maxTokens: customModel.maxTokens ?? merged[existingIndex].maxTokens,
|
||||
headers: customModel.headers
|
||||
? { ...merged[existingIndex].headers, ...customModel.headers }
|
||||
: merged[existingIndex].headers,
|
||||
compat: mergeCompat(merged[existingIndex].compat, customModel.compat),
|
||||
contextPromotionTarget:
|
||||
customModel.contextPromotionTarget ?? merged[existingIndex].contextPromotionTarget,
|
||||
premiumMultiplier: customModel.premiumMultiplier ?? merged[existingIndex].premiumMultiplier,
|
||||
} as Model<Api>);
|
||||
} else {
|
||||
merged.push(customModel);
|
||||
merged.push(finalizeCustomModel(customModel, { useDefaults: true }));
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
@@ -935,22 +1046,31 @@ export class ModelRegistry {
|
||||
if (discovered.length === 0) {
|
||||
return;
|
||||
}
|
||||
const merged = this.#mergeCustomModels(
|
||||
this.#models,
|
||||
const discoveredModels = this.#applyHardcodedModelPolicies(
|
||||
discovered.map(model => {
|
||||
const existing =
|
||||
this.find(model.provider, model.id) ??
|
||||
this.#models.find(candidate => candidate.provider === model.provider);
|
||||
return existing
|
||||
const existing = this.find(model.provider, model.id);
|
||||
if (existing) {
|
||||
return {
|
||||
...model,
|
||||
baseUrl: existing.baseUrl,
|
||||
headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
|
||||
};
|
||||
}
|
||||
const providerOverride = this.#providerOverrides.get(model.provider);
|
||||
return providerOverride
|
||||
? {
|
||||
...model,
|
||||
baseUrl: existing.baseUrl,
|
||||
headers: existing.headers ? { ...existing.headers, ...model.headers } : model.headers,
|
||||
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
|
||||
headers: providerOverride.headers
|
||||
? { ...model.headers, ...providerOverride.headers }
|
||||
: model.headers,
|
||||
}
|
||||
: model;
|
||||
}),
|
||||
);
|
||||
this.#models = this.#applyHardcodedModelPolicies(this.#applyModelOverrides(merged, this.#modelOverrides));
|
||||
const resolved = this.#mergeResolvedModels(this.#models, discoveredModels);
|
||||
const combined = this.#mergeCustomModels(resolved, this.#customModelOverlays);
|
||||
this.#models = this.#applyModelOverrides(combined, this.#modelOverrides);
|
||||
}
|
||||
|
||||
async #discoverProviderModels(
|
||||
@@ -1454,8 +1574,8 @@ export class ModelRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
#parseModels(config: ModelsConfig): Model<Api>[] {
|
||||
const models: Model<Api>[] = [];
|
||||
#parseModels(config: ModelsConfig): CustomModelOverlay[] {
|
||||
const models: CustomModelOverlay[] = [];
|
||||
|
||||
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
||||
const modelDefs = providerConfig.models ?? [];
|
||||
@@ -1464,7 +1584,7 @@ export class ModelRegistry {
|
||||
this.#customProviderApiKeys.set(providerName, providerConfig.apiKey);
|
||||
}
|
||||
for (const modelDef of modelDefs) {
|
||||
const model = buildCustomModel(
|
||||
const model = buildCustomModelOverlay(
|
||||
providerName,
|
||||
providerConfig.baseUrl!,
|
||||
providerConfig.api as Api | undefined,
|
||||
@@ -1473,7 +1593,6 @@ export class ModelRegistry {
|
||||
providerConfig.authHeader,
|
||||
providerConfig.compat,
|
||||
modelDef as CustomModelDefinitionLike,
|
||||
{ useDefaults: true },
|
||||
);
|
||||
if (!model) continue;
|
||||
models.push(model);
|
||||
@@ -1635,7 +1754,7 @@ export class ModelRegistry {
|
||||
config.authHeader,
|
||||
config.compat,
|
||||
modelDef as CustomModelDefinitionLike,
|
||||
{ useDefaults: false },
|
||||
{ useDefaults: true },
|
||||
);
|
||||
if (!model) {
|
||||
throw new Error(`Provider ${providerName}, model ${modelDef.id}: no "api" specified.`);
|
||||
|
||||
@@ -50,7 +50,13 @@ describe("ModelRegistry", () => {
|
||||
/** Create minimal provider config */
|
||||
function providerConfig(
|
||||
baseUrl: string,
|
||||
models: Array<{ id: string; name?: string; reasoning?: boolean; thinking?: ThinkingConfig }>,
|
||||
models: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
thinking?: ThinkingConfig;
|
||||
contextWindow?: number;
|
||||
}>,
|
||||
api: string = "anthropic-messages",
|
||||
) {
|
||||
return {
|
||||
@@ -64,7 +70,7 @@ describe("ModelRegistry", () => {
|
||||
thinking: m.thinking,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 100000,
|
||||
contextWindow: m.contextWindow ?? 100000,
|
||||
maxTokens: 8000,
|
||||
})),
|
||||
};
|
||||
@@ -88,6 +94,19 @@ describe("ModelRegistry", () => {
|
||||
fs.writeFileSync(modelsJsonPath, JSON.stringify({ providers }));
|
||||
}
|
||||
|
||||
function mockOpenAiCompatibleModels(url: string, modelIds: string[]) {
|
||||
return hookFetch(input => {
|
||||
const requestUrl = String(input);
|
||||
if (requestUrl === url) {
|
||||
return new Response(JSON.stringify({ data: modelIds.map(id => ({ id })) }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${requestUrl}`);
|
||||
});
|
||||
}
|
||||
|
||||
describe("baseUrl override (no custom models)", () => {
|
||||
test("overriding baseUrl keeps all built-in models", () => {
|
||||
writeRawModelsJson({
|
||||
@@ -422,13 +441,221 @@ describe("ModelRegistry", () => {
|
||||
expect(registry.find("openai", "gpt-5.4")?.contextWindow).toBe(1_000_000);
|
||||
});
|
||||
|
||||
test("custom gpt-5.4 replacement also applies the hardcoded context window policy", () => {
|
||||
writeModelsJson({
|
||||
openai: providerConfig("https://my-proxy.example.com/v1", [{ id: "gpt-5.4" }], "openai-responses"),
|
||||
test("custom gpt-5.4 replacement keeps the hardcoded context window when contextWindow is omitted", () => {
|
||||
writeRawModelsJson({
|
||||
openai: {
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
apiKey: "TEST_KEY",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "gpt-5.4" }],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("openai", "gpt-5.4")?.contextWindow).toBe(1_000_000);
|
||||
const model = registry.find("openai", "gpt-5.4");
|
||||
expect(model?.contextWindow).toBe(1_000_000);
|
||||
expect(model?.baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
});
|
||||
|
||||
test("custom-only gpt-5.4 provider keeps the hardcoded context window when contextWindow is omitted", () => {
|
||||
writeRawModelsJson({
|
||||
"my-proxy": {
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
apiKey: "TEST_KEY",
|
||||
api: "openai-responses",
|
||||
models: [{ id: "gpt-5.4" }],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const model = registry.find("my-proxy", "gpt-5.4");
|
||||
expect(model?.contextWindow).toBe(1_000_000);
|
||||
expect(model?.baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
});
|
||||
|
||||
test("custom gpt-5.4 replacement preserves its explicit context window", () => {
|
||||
writeModelsJson({
|
||||
openai: providerConfig(
|
||||
"https://my-proxy.example.com/v1",
|
||||
[{ id: "gpt-5.4", contextWindow: 256000 }],
|
||||
"openai-responses",
|
||||
),
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("openai", "gpt-5.4")?.contextWindow).toBe(256000);
|
||||
});
|
||||
|
||||
test("modelOverrides can still patch a custom gpt-5.4 replacement", () => {
|
||||
writeRawModelsJson({
|
||||
openai: {
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
apiKey: "TEST_KEY",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 256000,
|
||||
maxTokens: 128000,
|
||||
},
|
||||
],
|
||||
modelOverrides: {
|
||||
"gpt-5.4": {
|
||||
contextWindow: 512000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("openai", "gpt-5.4")?.contextWindow).toBe(512000);
|
||||
});
|
||||
|
||||
test("discoverable bundled replacement survives refresh", async () => {
|
||||
writeModelsJson({
|
||||
openai: providerConfig(
|
||||
"https://my-proxy.example.com/v1",
|
||||
[{ id: "gpt-5.4", name: "Proxy GPT-5.4", contextWindow: 256000 }],
|
||||
"openai-responses",
|
||||
),
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("openai", "gpt-5.4")?.name).toBe("Proxy GPT-5.4");
|
||||
expect(registry.find("openai", "gpt-5.4")?.contextWindow).toBe(256000);
|
||||
|
||||
using _hook = mockOpenAiCompatibleModels("https://my-proxy.example.com/v1/models", ["gpt-5.4"]);
|
||||
await registry.refreshProvider("openai", "online");
|
||||
|
||||
const model = registry.find("openai", "gpt-5.4");
|
||||
expect(model?.name).toBe("Proxy GPT-5.4");
|
||||
expect(model?.contextWindow).toBe(256000);
|
||||
expect(model?.baseUrl).toBe("https://my-proxy.example.com/v1");
|
||||
});
|
||||
|
||||
test("discoverable custom-only gpt-5.4 survives refresh", async () => {
|
||||
writeRawModelsJson({
|
||||
"custom-local": {
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
apiKey: "TEST_KEY",
|
||||
api: "openai-responses",
|
||||
discovery: { type: "llama.cpp" },
|
||||
models: [{ id: "gpt-5.4" }],
|
||||
},
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("custom-local", "gpt-5.4")?.contextWindow).toBe(1_000_000);
|
||||
|
||||
using _hook = mockOpenAiCompatibleModels("http://127.0.0.1:8080/models", ["gpt-5.4"]);
|
||||
await registry.refreshProvider("custom-local", "online");
|
||||
|
||||
const model = registry.find("custom-local", "gpt-5.4");
|
||||
expect(model?.contextWindow).toBe(1_000_000);
|
||||
expect(model?.baseUrl).toBe("http://127.0.0.1:8080");
|
||||
});
|
||||
|
||||
test("discoverable custom compat survives refresh", async () => {
|
||||
writeRawModelsJson({
|
||||
openai: {
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
apiKey: "TEST_KEY",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
compat: {
|
||||
extraBody: { source: "proxy" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("openai", "gpt-5.4")?.compat?.extraBody).toEqual({ source: "proxy" });
|
||||
|
||||
using _hook = mockOpenAiCompatibleModels("https://my-proxy.example.com/v1/models", ["gpt-5.4"]);
|
||||
await registry.refreshProvider("openai", "online");
|
||||
|
||||
expect(registry.find("openai", "gpt-5.4")?.compat?.extraBody).toEqual({ source: "proxy" });
|
||||
});
|
||||
|
||||
test("modelOverrides still apply after discoverable refresh", async () => {
|
||||
writeRawModelsJson({
|
||||
openai: {
|
||||
baseUrl: "https://my-proxy.example.com/v1",
|
||||
apiKey: "TEST_KEY",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
contextWindow: 256000,
|
||||
},
|
||||
],
|
||||
modelOverrides: {
|
||||
"gpt-5.4": {
|
||||
contextWindow: 512000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("openai", "gpt-5.4")?.contextWindow).toBe(512000);
|
||||
|
||||
using _hook = mockOpenAiCompatibleModels("https://my-proxy.example.com/v1/models", ["gpt-5.4"]);
|
||||
await registry.refreshProvider("openai", "online");
|
||||
|
||||
expect(registry.find("openai", "gpt-5.4")?.contextWindow).toBe(512000);
|
||||
});
|
||||
|
||||
test("newly discovered ids inherit provider fields, not another model's custom fields", async () => {
|
||||
writeRawModelsJson({
|
||||
openai: {
|
||||
baseUrl: "https://provider.example.com/v1",
|
||||
headers: { "X-Provider": "provider" },
|
||||
apiKey: "TEST_KEY",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
baseUrl: "https://special.example.com/v1",
|
||||
headers: { "X-Model": "special" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
expect(registry.find("openai", "gpt-5.4")?.baseUrl).toBe("https://special.example.com/v1");
|
||||
|
||||
using _hook = mockOpenAiCompatibleModels("https://provider.example.com/v1/models", ["gpt-5.4", "gpt-5.5"]);
|
||||
await registry.refreshProvider("openai", "online");
|
||||
|
||||
const discovered = registry.find("openai", "gpt-5.5");
|
||||
expect(discovered?.baseUrl).toBe("https://provider.example.com/v1");
|
||||
expect(discovered?.headers?.["X-Provider"]).toBe("provider");
|
||||
expect(discovered?.headers?.["X-Model"]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("provider compat overlays preserve bundled model compat", () => {
|
||||
writeRawModelsJson({
|
||||
"minimax-code": {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
apiKey: "TEST_KEY",
|
||||
api: "openai-completions",
|
||||
compat: {
|
||||
extraBody: { source: "proxy" },
|
||||
},
|
||||
models: [{ id: "MiniMax-M2.5" }],
|
||||
},
|
||||
});
|
||||
|
||||
const registry = new ModelRegistry(authStorage, modelsJsonPath);
|
||||
const model = registry.find("minimax-code", "MiniMax-M2.5");
|
||||
expect(model?.compat?.thinkingFormat).toBe("zai");
|
||||
expect(model?.compat?.reasoningContentField).toBe("reasoning_content");
|
||||
expect(model?.compat?.extraBody).toEqual({ source: "proxy" });
|
||||
});
|
||||
|
||||
test("removing custom models from models.json keeps built-in provider models", async () => {
|
||||
|
||||
Reference in New Issue
Block a user