From 3d3a8e26e3067c94b3524eb10fab095c859b9ddd Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Wed, 29 Apr 2026 21:49:49 +0200 Subject: [PATCH] fix(sf): tighten mimo and openrouter model policy --- .../extensions/sf/auto-model-selection.ts | 1 + src/resources/extensions/sf/model-identity.ts | 20 +++++++ .../extensions/sf/preferences-models.ts | 33 ++++++++-- .../sf/tests/auto-model-selection.test.ts | 38 ++++++++++++ .../sf/tests/model-identity.test.ts | 16 +++++ .../sf/tests/provider-model-allow.test.ts | 60 +++++++++++++++++++ 6 files changed, 164 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/sf/auto-model-selection.ts b/src/resources/extensions/sf/auto-model-selection.ts index ee9135951..6c62226a8 100644 --- a/src/resources/extensions/sf/auto-model-selection.ts +++ b/src/resources/extensions/sf/auto-model-selection.ts @@ -158,6 +158,7 @@ const BARE_MODEL_FAMILY_PRIORITY: Array<{ "xiaomi-token-plan-ams", "xiaomi-token-plan-sgp", "xiaomi-token-plan-cn", + "opencode-go", ], }, ]; diff --git a/src/resources/extensions/sf/model-identity.ts b/src/resources/extensions/sf/model-identity.ts index 20529ec09..872f56b04 100644 --- a/src/resources/extensions/sf/model-identity.ts +++ b/src/resources/extensions/sf/model-identity.ts @@ -22,6 +22,8 @@ export function normalizedModelName(model: { }): string { const provider = model.provider?.toLowerCase(); const id = model.id.toLowerCase(); + const mimoName = normalizedMiMoModelName(id); + if (mimoName) return mimoName; if ( (provider === "kimi-coding" && id === "kimi-for-coding") || id === "kimi-k2.6" || @@ -41,6 +43,24 @@ export function normalizedModelName(model: { return model.id; } +function normalizedMiMoModelName(id: string): string | undefined { + const bareId = id.startsWith("xiaomi/") ? id.slice("xiaomi/".length) : id; + switch (bareId) { + case "mimo-v2.5-pro": + return "MiMo V2.5 Pro"; + case "mimo-v2.5": + return "MiMo V2.5"; + case "mimo-v2-pro": + return "MiMo V2 Pro"; + case "mimo-v2-omni": + return "MiMo V2 Omni"; + case "mimo-v2-flash": + return "MiMo V2 Flash"; + default: + return undefined; + } +} + /** * Return a display label that preserves both model identity and wire route. * diff --git a/src/resources/extensions/sf/preferences-models.ts b/src/resources/extensions/sf/preferences-models.ts index bf8d21a43..2b808c281 100644 --- a/src/resources/extensions/sf/preferences-models.ts +++ b/src/resources/extensions/sf/preferences-models.ts @@ -55,19 +55,41 @@ function resolveProviderModelAllowList( ); } +function providerModelAllowEntryMatches( + allowedModel: string, + modelKey: string, +): boolean { + const allowedKey = allowedModel.trim().toLowerCase(); + if (!allowedKey) return false; + if (allowedKey === modelKey) return true; + if (allowedKey.startsWith(":")) return modelKey.endsWith(allowedKey); + if (!allowedKey.includes("*")) return false; + const pattern = `^${allowedKey + .split("*") + .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) + .join(".*")}$`; + return new RegExp(pattern).test(modelKey); +} + export function isProviderModelAllowed( provider: string, modelId: string, providerModelAllow: ProviderModelAllowList | undefined, ): boolean { + const modelKey = modelId.trim().toLowerCase(); + if ( + provider.toLowerCase() === "openrouter" && + !providerModelAllowEntryMatches(":free", modelKey) + ) { + return false; + } const allowedModels = resolveProviderModelAllowList( providerModelAllow, provider, ); if (allowedModels === undefined) return true; - const modelKey = modelId.trim().toLowerCase(); - return allowedModels.some( - (allowedModel) => allowedModel.trim().toLowerCase() === modelKey, + return allowedModels.some((allowedModel) => + providerModelAllowEntryMatches(allowedModel, modelKey), ); } @@ -77,7 +99,10 @@ export function filterModelsByProviderModelAllow< models: readonly T[], providerModelAllow: ProviderModelAllowList | undefined, ): T[] { - if (!providerModelAllow || Object.keys(providerModelAllow).length === 0) + if ( + (!providerModelAllow || Object.keys(providerModelAllow).length === 0) && + !models.some((model) => model.provider.toLowerCase() === "openrouter") + ) return [...models]; return models.filter((model) => isProviderModelAllowed(model.provider, model.id, providerModelAllow), diff --git a/src/resources/extensions/sf/tests/auto-model-selection.test.ts b/src/resources/extensions/sf/tests/auto-model-selection.test.ts index 62793602a..ffb8c90f8 100644 --- a/src/resources/extensions/sf/tests/auto-model-selection.test.ts +++ b/src/resources/extensions/sf/tests/auto-model-selection.test.ts @@ -395,6 +395,44 @@ test("resolveModelId: bare Xiaomi IDs prefer xiaomi direct provider", () => { assert.equal(result.provider, "xiaomi"); }); +test("resolveModelId: bare Xiaomi IDs can use OpenCode Go exact model when direct Xiaomi is absent", () => { + const availableModels = [ + { id: "mimo-v2-pro", provider: "opencode-go" }, + { id: "mimo-v2-omni", provider: "opencode-go" }, + ]; + + const result = resolveModelId("mimo-v2-pro", availableModels, "openrouter"); + assert.ok(result, "should resolve exact MiMo model from OpenCode Go"); + assert.equal(result.provider, "opencode-go"); + assert.equal(result.id, "mimo-v2-pro"); +}); + +test("resolveModelId: Xiaomi MiMo V2.5 Pro does not fall back to V2", () => { + const availableModels = [ + { id: "mimo-v2-pro", provider: "xiaomi" }, + { id: "mimo-v2-omni", provider: "xiaomi" }, + { id: "xiaomi/mimo-v2-pro", provider: "openrouter" }, + ]; + + const result = resolveModelId( + "mimo-v2.5-pro", + availableModels, + "xiaomi", + ); + assert.equal(result, undefined); +}); + +test("resolveModelId: Xiaomi MiMo V2.5 non-pro does not fall back to V2", () => { + const availableModels = [ + { id: "mimo-v2-pro", provider: "xiaomi" }, + { id: "mimo-v2-omni", provider: "xiaomi" }, + { id: "mimo-v2-flash", provider: "xiaomi" }, + ]; + + const result = resolveModelId("mimo-v2.5", availableModels, "xiaomi"); + assert.equal(result, undefined); +}); + // ─── selectAndApplyModel verbose-gating tests ────────────────────────── test("model change notify in selectAndApplyModel is gated behind verbose flag", () => { diff --git a/src/resources/extensions/sf/tests/model-identity.test.ts b/src/resources/extensions/sf/tests/model-identity.test.ts index a6fec39f7..8303ddb18 100644 --- a/src/resources/extensions/sf/tests/model-identity.test.ts +++ b/src/resources/extensions/sf/tests/model-identity.test.ts @@ -30,3 +30,19 @@ test("model identity: K2.5 remains distinct from K2.6", () => { assert.equal(normalizedModelName(model), "Kimi K2.5"); assert.equal(formatModelIdentity(model), "Kimi K2.5 (kimi-coding/k2p5)"); }); + +test("model identity: Xiaomi MiMo V2 and V2.5 lines stay distinct", () => { + const v25Pro = { provider: "xiaomi", id: "mimo-v2.5-pro" }; + const v2Pro = { provider: "xiaomi", id: "mimo-v2-pro" }; + const openRouterOmni = { provider: "openrouter", id: "xiaomi/mimo-v2-omni" }; + + assert.equal(normalizedModelName(v25Pro), "MiMo V2.5 Pro"); + assert.equal(formatModelIdentity(v25Pro), "MiMo V2.5 Pro (xiaomi/mimo-v2.5-pro)"); + assert.equal(normalizedModelName(v2Pro), "MiMo V2 Pro"); + assert.equal(formatModelIdentity(v2Pro), "MiMo V2 Pro (xiaomi/mimo-v2-pro)"); + assert.equal(normalizedModelName(openRouterOmni), "MiMo V2 Omni"); + assert.equal( + formatModelIdentity(openRouterOmni), + "MiMo V2 Omni (openrouter/xiaomi/mimo-v2-omni)", + ); +}); diff --git a/src/resources/extensions/sf/tests/provider-model-allow.test.ts b/src/resources/extensions/sf/tests/provider-model-allow.test.ts index 44df89f41..6d9511a99 100644 --- a/src/resources/extensions/sf/tests/provider-model-allow.test.ts +++ b/src/resources/extensions/sf/tests/provider-model-allow.test.ts @@ -56,6 +56,66 @@ test("provider_model_allow: provider absent from allow-list is unrestricted", () assert.ok(filtered.some((m) => m.provider === "zai" && m.id === "glm-5")); }); +test("provider_model_allow: OpenRouter defaults to free models only", () => { + const models = [ + { provider: "openrouter", id: "qwen/qwen3-4b:free" }, + { provider: "openrouter", id: "z-ai/glm-5.1" }, + { provider: "zai", id: "glm-5.1" }, + ]; + + const filtered = filterModelsByProviderModelAllow(models, undefined); + + assert.deepEqual( + filtered.map((m) => `${m.provider}/${m.id}`), + ["openrouter/qwen/qwen3-4b:free", "zai/glm-5.1"], + ); +}); + +test("provider_model_allow: supports OpenRouter free-model suffix patterns", () => { + const models = [ + { provider: "openrouter", id: "qwen/qwen3-coder:free" }, + { provider: "openrouter", id: "openai/gpt-oss-120b:free" }, + { provider: "openrouter", id: "minimax/minimax-m2.5" }, + { provider: "zai", id: "glm-4.6" }, + ]; + + const suffixFiltered = filterModelsByProviderModelAllow(models, { + openrouter: [":free"], + }); + assert.deepEqual( + suffixFiltered.map((m) => `${m.provider}/${m.id}`), + [ + "openrouter/qwen/qwen3-coder:free", + "openrouter/openai/gpt-oss-120b:free", + "zai/glm-4.6", + ], + ); + + const globFiltered = filterModelsByProviderModelAllow(models, { + openrouter: ["*:free"], + }); + assert.deepEqual( + globFiltered.map((m) => `${m.provider}/${m.id}`), + suffixFiltered.map((m) => `${m.provider}/${m.id}`), + ); +}); + +test("provider_model_allow: OpenRouter paid models stay blocked even when explicitly listed", () => { + const models = [ + { provider: "openrouter", id: "z-ai/glm-5.1" }, + { provider: "openrouter", id: "qwen/qwen3-4b:free" }, + ]; + + const filtered = filterModelsByProviderModelAllow(models, { + openrouter: ["z-ai/glm-5.1", "qwen/qwen3-4b:free"], + }); + + assert.deepEqual( + filtered.map((m) => `${m.provider}/${m.id}`), + ["openrouter/qwen/qwen3-4b:free"], + ); +}); + test("provider_model_allow: validates shape and normalizes provider IDs", () => { const result = validatePreferences({ provider_model_allow: {