diff --git a/changelog.d/features/13214-auto-routing-families.md b/changelog.d/features/13214-auto-routing-families.md new file mode 100644 index 0000000000..82057d2053 --- /dev/null +++ b/changelog.d/features/13214-auto-routing-families.md @@ -0,0 +1 @@ +- **feat(providers):** Add `auto/kimi`, `auto/qwen`, `auto/deepseek`, `auto/gpt`, and the `auto/claude-haiku` fast variant to the built-in routing catalog, including bare `k3` models on Kimi coding and web backends (issue #13214). diff --git a/open-sse/services/autoCombo/builtinCatalog.ts b/open-sse/services/autoCombo/builtinCatalog.ts index 64963c2e98..16f0810867 100644 --- a/open-sse/services/autoCombo/builtinCatalog.ts +++ b/open-sse/services/autoCombo/builtinCatalog.ts @@ -43,6 +43,7 @@ export const AUTO_TEMPLATE_VARIANTS: Record = { "auto/smart": "smart", "auto/claude-opus": "smart", "auto/claude-sonnet": "coding", + "auto/claude-haiku": "fast", "auto/best-free": "cheap", // Subscription-first routing (see `subscriptionLadder.ts`). `auto/subscription` // maps to no weight variant on purpose: its pool is already restricted to diff --git a/open-sse/services/autoCombo/modelFamily.ts b/open-sse/services/autoCombo/modelFamily.ts index ab3b950cea..aa333eb1ca 100644 --- a/open-sse/services/autoCombo/modelFamily.ts +++ b/open-sse/services/autoCombo/modelFamily.ts @@ -13,7 +13,18 @@ * in isolation without touching the DB/registry-backed virtual factory. */ -export type ModelFamily = "glm" | "minimax" | "mimo" | "zai" | "gemma" | "llama" | "gemini"; +export type ModelFamily = + | "glm" + | "minimax" + | "mimo" + | "zai" + | "gemma" + | "llama" + | "gemini" + | "kimi" + | "qwen" + | "deepseek" + | "gpt"; export const MODEL_FAMILIES: readonly ModelFamily[] = [ "glm", @@ -23,6 +34,10 @@ export const MODEL_FAMILIES: readonly ModelFamily[] = [ "gemma", "llama", "gemini", + "kimi", + "qwen", + "deepseek", + "gpt", ]; const MODEL_FAMILY_SET: ReadonlySet = new Set(MODEL_FAMILIES); @@ -36,6 +51,10 @@ const FAMILY_ID_PATTERNS: ReadonlyArray<{ family: ModelFamily; pattern: RegExp } { family: "gemma", pattern: /^gemma-/i }, { family: "llama", pattern: /^llama-/i }, { family: "gemini", pattern: /^gemini-/i }, + { family: "kimi", pattern: /^kimi-/i }, + { family: "qwen", pattern: /^qwen/i }, + { family: "deepseek", pattern: /^deepseek-/i }, + { family: "gpt", pattern: /^gpt-/i }, ]; /** @@ -79,7 +98,8 @@ interface FamilyPoolCandidate { /** * Build the candidate filter for `auto/`. Provider-override families * (currently only `zai`) filter by connection provider id; every other family - * filters by `detectModelFamily(model) === family`. + * filters by `detectModelFamily(model) === family`, with a provider-scoped + * exception for Kimi backends exposing the bare `k3` model id. */ export function buildFamilyCandidateFilter( family: ModelFamily @@ -88,7 +108,17 @@ export function buildFamilyCandidateFilter( if (providerOverride) { return (candidate) => candidate.provider === providerOverride; } - return (candidate) => detectModelFamily(candidate.model) === family; + return (candidate) => { + if (detectModelFamily(candidate.model) === family) return true; + // Coding OAuth/API-key and web share the bare k3 id. Do not classify an + // unrelated provider's k3 (or other models on these providers) as Kimi. + const bareModel = candidate.model.slice(candidate.model.lastIndexOf("/") + 1); + return ( + family === "kimi" && + ["kimi-coding", "kimi-coding-apikey", "kimi-web"].includes(candidate.provider) && + bareModel.toLowerCase() === "k3" + ); + }; } /** Advertised `auto/` catalog ids (#6453), e.g. `auto/glm`, `auto/minimax`. */ diff --git a/tests/unit/autoCombo/provider-family-combos.test.ts b/tests/unit/autoCombo/provider-family-combos.test.ts index bdb492e6a3..0930e2f3ca 100644 --- a/tests/unit/autoCombo/provider-family-combos.test.ts +++ b/tests/unit/autoCombo/provider-family-combos.test.ts @@ -16,6 +16,7 @@ import path from "node:path"; import { detectModelFamily, + buildFamilyCandidateFilter, isValidModelFamily, AUTO_FAMILY_IDS, } from "../../../open-sse/services/autoCombo/modelFamily"; @@ -67,7 +68,7 @@ describe("detectModelFamily (pure)", () => { }); it("returns null for unrelated model ids", () => { - assert.equal(detectModelFamily("gpt-4o"), null); + assert.equal(detectModelFamily("unrelated-model"), null); assert.equal(detectModelFamily(""), null); assert.equal(detectModelFamily(null), null); }); @@ -78,22 +79,38 @@ describe("detectModelFamily (pure)", () => { assert.equal(detectModelFamily("zai-glm-5.2"), null); }); - it("isValidModelFamily accepts exactly the 7 advertised families", () => { - for (const family of ["glm", "minimax", "mimo", "zai", "gemma", "llama", "gemini"]) { + it("isValidModelFamily accepts exactly the 11 advertised families", () => { + for (const family of [ + "glm", + "minimax", + "mimo", + "zai", + "gemma", + "llama", + "gemini", + "kimi", + "qwen", + "deepseek", + "gpt", + ]) { assert.equal(isValidModelFamily(family), true); } - assert.equal(isValidModelFamily("gpt"), false); + assert.equal(isValidModelFamily("unknown"), false); assert.equal(isValidModelFamily(undefined), false); }); it("advertises exactly one auto/ catalog id per family", () => { assert.deepEqual([...AUTO_FAMILY_IDS].sort(), [ + "auto/deepseek", "auto/gemini", "auto/gemma", "auto/glm", + "auto/gpt", + "auto/kimi", "auto/llama", "auto/mimo", "auto/minimax", + "auto/qwen", "auto/zai", ]); }); @@ -230,9 +247,127 @@ describe("auto/ materialization (#6453)", () => { }); it("isRecognizedBuiltinAuto recognizes every auto/ id", () => { - for (const family of ["glm", "minimax", "mimo", "zai", "gemma", "llama", "gemini"]) { + for (const family of [ + "glm", + "minimax", + "mimo", + "zai", + "gemma", + "llama", + "gemini", + "kimi", + "qwen", + "deepseek", + "gpt", + ]) { assert.equal(builtinCatalog.isRecognizedBuiltinAuto(`auto/${family}`, family), true); } assert.equal(builtinCatalog.isRecognizedBuiltinAuto("auto/unknownfam", "unknownfam"), false); }); }); + +describe("additional auto-routing families (#13214)", () => { + it("materializes Qwen, DeepSeek and GPT without crossing family boundaries", async () => { + for (const [provider, defaultModel] of [ + ["qwen", "qwen3-14b"], + ["deepseek", "deepseek-chat"], + ["openai", "gpt-4o"], + ]) { + await providersDb.createProviderConnection({ + provider, + defaultModel, + authType: "apikey", + name: provider, + apiKey: "test", + }); + } + for (const [family, provider] of [ + ["qwen", "qwen"], + ["deepseek", "deepseek"], + ["gpt", "openai"], + ]) { + const combo = await builtinCatalog.createBuiltinAutoCombo(`auto/${family}`, family); + assert.equal(combo.id, `auto/${family}`); + assert.equal(combo.strategy, "auto"); + assert.ok( + combo.models.some((model) => model.providerId === provider), + family + ); + assert.ok( + combo.models.every((model) => detectModelFamily(model.model) === family), + family + ); + } + }); + + it("materializes Kimi across coding, web and Moonshot without unrelated models", async () => { + for (const [provider, defaultModel] of [ + ["kimi-coding", "k3"], + ["kimi-coding-apikey", "k3"], + ["kimi-web", "k3"], + ["moonshot", "kimi-k2.5"], + ["openai", "gpt-4o"], + ]) { + await providersDb.createProviderConnection({ + provider, + defaultModel, + authType: "apikey", + name: provider, + apiKey: "test", + }); + } + const combo = await builtinCatalog.createBuiltinAutoCombo("auto/kimi", "kimi"); + assert.equal(combo.id, "auto/kimi"); + assert.equal(combo.strategy, "auto"); + for (const provider of ["kimi-coding", "kimi-coding-apikey", "kimi-web", "moonshot"]) { + assert.ok( + combo.models.some((model) => model.providerId === provider), + provider + ); + } + assert.ok(combo.models.every((model) => model.providerId !== "openai")); + }); + + it("detects new families across provider-prefixed and bare model ids", () => { + for (const [model, family] of [ + ["moonshot/kimi-k3", "kimi"], + ["QWEN3-14B", "qwen"], + ["qwen2.5-coder-32b-instruct", "qwen"], + ["qwen3.5-plus", "qwen"], + ["deepseek/deepseek-chat", "deepseek"], + ["openai/gpt-4o", "gpt"], + ]) { + assert.equal(detectModelFamily(model), family); + } + assert.equal(detectModelFamily("k3"), null); + assert.equal(detectModelFamily("not-qwen3"), null); + }); + + it("includes Kimi k3 only on known Kimi backends, alongside prefix matches", () => { + const filter = buildFamilyCandidateFilter("kimi"); + for (const provider of ["kimi-coding", "kimi-web", "kimi-coding-apikey"]) { + assert.equal(filter({ provider, model: "k3" }), true); + assert.equal(filter({ provider, model: `${provider}/k3` }), true); + assert.equal(filter({ provider, model: "gpt-4o" }), false); + } + assert.equal(filter({ provider: "moonshot", model: "kimi-k3" }), true); + assert.equal(filter({ provider: "custom", model: "kimi-k2.5" }), true); + assert.equal(filter({ provider: "custom", model: "k3" }), false); + assert.equal(buildFamilyCandidateFilter("gpt")({ provider: "kimi-web", model: "k3" }), false); + assert.equal(buildFamilyCandidateFilter("zai")({ provider: "glm", model: "glm-5.2" }), false); + }); + + it("maps Haiku to fast while preserving Opus and Sonnet variants", () => { + for (const [suffix, variant] of [ + ["claude-haiku", "fast"], + ["claude-opus", "smart"], + ["claude-sonnet", "coding"], + ]) { + assert.equal(builtinCatalog.AUTO_TEMPLATE_VARIANTS[`auto/${suffix}`], variant); + assert.equal(builtinCatalog.isRecognizedBuiltinAuto(`auto/${suffix}`, suffix), true); + assert.deepEqual(builtinCatalog.resolveBuiltinAutoSpec(`auto/${suffix}`, suffix), { + variant, + }); + } + }); +});