fix(core): stop falling back to delisted OpenRouter checkModel (#300)

- openrouter checkModel 换成长期存在的 openrouter/auto,静态清单删除已下架的 google/gemma-2-9b-it:free
- 动态模型清单的服务(openrouter/newapi/kkaiapi/ppio/siliconcloud,原有 ollama)放行未列出的模型 id,
  用户显式配置的模型不再被静默替换成 checkModel
- 严格服务的模型归属校验行为不变
This commit is contained in:
Ma
2026-07-07 13:12:43 +08:00
parent 68ae2274db
commit 22e5af59ae
4 changed files with 88 additions and 4 deletions
@@ -374,6 +374,70 @@ describe("resolveEffectiveLLMConfig", () => {
})).rejects.toThrow(/模型.*kimi-k2\.5.*不属于.*google/);
});
it("openrouter 配置了不在静态 bank 的模型时保留用户模型,不回退 checkModelissue #300", async () => {
await writeProject({
configSource: "studio",
service: "openrouter",
services: [{ service: "openrouter" }],
defaultModel: "moonshotai/kimi-k2-thinking",
});
await writeSecrets({ openrouter: { apiKey: "sk-or" } });
const result = await resolveEffectiveLLMConfig({
consumer: "studio",
projectRoot: root,
envLayers: { global: {}, project: {}, process: {} },
requireApiKey: true,
});
expect(result.llm.service).toBe("openrouter");
expect(result.llm.baseUrl).toBe("https://openrouter.ai/api/v1");
expect(result.llm.model).toBe("moonshotai/kimi-k2-thinking");
});
it("CLI env 显式指定 openrouter 未列出模型时也直接透传", async () => {
await writeProject({
configSource: "studio",
service: "openrouter",
services: [{ service: "openrouter" }],
defaultModel: "openrouter/auto",
});
await writeSecrets({ openrouter: { apiKey: "sk-or" } });
const result = await resolveEffectiveLLMConfig({
consumer: "cli",
projectRoot: root,
envLayers: {
global: {},
project: { INKOS_LLM_MODEL: "z-ai/glm-4.7" },
process: {},
},
});
expect(result.llm.service).toBe("openrouter");
expect(result.llm.model).toBe("z-ai/glm-4.7");
expect(result.diagnostics.modelSource).toBe("env");
});
it("openrouter 完全没配模型时才回退到 checkModel", async () => {
await writeProject({
configSource: "studio",
service: "openrouter",
services: [{ service: "openrouter" }],
});
await writeSecrets({ openrouter: { apiKey: "sk-or" } });
const result = await resolveEffectiveLLMConfig({
consumer: "studio",
projectRoot: root,
envLayers: { global: {}, project: {}, process: {} },
requireApiKey: true,
});
expect(result.llm.service).toBe("openrouter");
expect(result.llm.model).toBe("openrouter/auto");
});
it("CLI env 指向 Ollama 时允许用户本地安装的动态模型", async () => {
await writeProject({
configSource: "studio",
@@ -56,7 +56,7 @@ describe("Layer 2 优先级(聚合入口精简后)", () => {
});
it("OpenRouter 专属带后缀 id:free)命中 openrouter provider", () => {
const hit = lookupModel("custom", "google/gemma-2-9b-it:free");
const hit = lookupModel("custom", "meta-llama/llama-3.1-8b-instruct:free");
expect(hit).toBeDefined();
expect(hit?.maxOutput).toBe(4096);
});
@@ -18,7 +18,9 @@ export const OPENROUTER: InkosEndpoint = {
group: "aggregator",
api: "openai-responses",
baseUrl: "https://openrouter.ai/api/v1",
checkModel: "google/gemma-2-9b-it:free",
// openrouter/auto 是 OpenRouter 官方的自动路由入口,长期存在;
// 具体模型 id(如 google/gemma-2-9b-it:free)会随上游下架失效(issue #300)。
checkModel: "openrouter/auto",
temperatureRange: [0, 2],
defaultTemperature: 0.7,
writingTemperature: 1,
@@ -82,6 +84,5 @@ export const OPENROUTER: InkosEndpoint = {
{ id: "meta-llama/llama-3.3-70b-instruct:free", maxOutput: 4096, contextWindowTokens: 65536 },
{ id: "qwen/qwen-2-7b-instruct:free", maxOutput: 4096, contextWindowTokens: 32768 },
{ id: "meta-llama/llama-3.1-8b-instruct:free", maxOutput: 4096, contextWindowTokens: 131072 },
{ id: "google/gemma-2-9b-it:free", maxOutput: 4096, contextWindowTokens: 8192 },
],
};
@@ -464,8 +464,27 @@ function modelBelongsToService(service: string, model: string): boolean {
return endpoint.models.some((knownModel) => knownModel.id.toLowerCase() === model.toLowerCase());
}
/**
* 这些服务的可用模型清单是动态的,静态 bank 必然滞后,
* 所以用户显式配置的模型 id 直接透传,不做 bank 白名单校验(issue #300):
* - ollama:本地装了什么模型就有什么模型
* - openrouter:聚合 350+ 上游模型,上游随时增删,bank 只列常用子集
* - newapi:自建中转网关,模型清单由部署方自定义,bank 为空
* - kkaiapi:多家大模型的 OpenAI 兼容中转站,上游清单随时变化
* - ppio:聚合平台,每周都有新模型 id,bank 只维护主流子集
* - siliconcloud:聚合平台,新模型上架频繁,bank 只维护主流子集
*/
const SERVICES_WITH_DYNAMIC_MODELS: ReadonlySet<string> = new Set([
"ollama",
"openrouter",
"newapi",
"kkaiapi",
"ppio",
"siliconcloud",
]);
function serviceAllowsUnlistedModels(service: string): boolean {
return service === "ollama";
return SERVICES_WITH_DYNAMIC_MODELS.has(service);
}
function serviceEntryKey(entry: ServiceConfigEntry): string {