355 lines
9.9 KiB
TypeScript
355 lines
9.9 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
loadEffectiveSFPreferences,
|
|
validatePreferences,
|
|
} from "../preferences.ts";
|
|
import {
|
|
filterModelsByProviderModelAllow,
|
|
isProviderAllowedByLists,
|
|
} from "../preferences-models.ts";
|
|
|
|
test("provider_model_allow: provider in allow-list and model in list passes", () => {
|
|
const models = [
|
|
{ provider: "minimax", id: "MiniMax-M2.7" },
|
|
{ provider: "minimax", id: "MiniMax-M2" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(models, {
|
|
minimax: ["MiniMax-M2.7"],
|
|
});
|
|
|
|
assert.deepEqual(
|
|
filtered.map((m) => `${m.provider}/${m.id}`),
|
|
["minimax/MiniMax-M2.7"],
|
|
);
|
|
});
|
|
|
|
test("provider_model_allow: provider in allow-list and model not in list is filtered", () => {
|
|
const models = [
|
|
{ provider: "minimax", id: "MiniMax-M2" },
|
|
{ provider: "zai", id: "glm-5" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(models, {
|
|
minimax: ["MiniMax-M2.7"],
|
|
});
|
|
|
|
assert.deepEqual(
|
|
filtered.map((m) => `${m.provider}/${m.id}`),
|
|
["zai/glm-5"],
|
|
"minimax/MiniMax-M2 is removed so selection can fall through to the next provider",
|
|
);
|
|
});
|
|
|
|
test("provider_model_allow: provider absent from allow-list is unrestricted", () => {
|
|
const models = [
|
|
{ provider: "minimax", id: "MiniMax-M2" },
|
|
{ provider: "zai", id: "glm-5" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(models, {
|
|
minimax: ["MiniMax-M2.7"],
|
|
});
|
|
|
|
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: OpenCode defaults to free models while OpenCode Go stays unrestricted", () => {
|
|
const models = [
|
|
{ provider: "opencode", id: "big-pickle" },
|
|
{ provider: "opencode", id: "gpt-5-nano" },
|
|
{ provider: "opencode", id: "minimax-m2.5-free" },
|
|
{ provider: "opencode", id: "nemotron-3-super-free" },
|
|
{ provider: "opencode", id: "gpt-5.4" },
|
|
{ provider: "opencode", id: "kimi-k2.5" },
|
|
{ provider: "opencode-go", id: "minimax-m2.7" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(models, undefined);
|
|
|
|
assert.deepEqual(
|
|
filtered.map((m) => `${m.provider}/${m.id}`),
|
|
[
|
|
"opencode/big-pickle",
|
|
"opencode/gpt-5-nano",
|
|
"opencode/minimax-m2.5-free",
|
|
"opencode/nemotron-3-super-free",
|
|
"opencode-go/minimax-m2.7",
|
|
],
|
|
);
|
|
});
|
|
|
|
test("provider_model_allow: hides Xiaomi token-plan regional aliases", () => {
|
|
const models = [
|
|
{ provider: "xiaomi", id: "mimo-v2-pro" },
|
|
{ provider: "xiaomi-token-plan-ams", id: "mimo-v2-pro" },
|
|
{ provider: "xiaomi-token-plan-cn", id: "mimo-v2-pro" },
|
|
{ provider: "xiaomi-token-plan-sgp", id: "mimo-v2-pro" },
|
|
{ provider: "opencode-go", id: "mimo-v2-pro" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(models, undefined);
|
|
|
|
assert.deepEqual(
|
|
filtered.map((m) => `${m.provider}/${m.id}`),
|
|
["xiaomi/mimo-v2-pro", "opencode-go/mimo-v2-pro"],
|
|
);
|
|
});
|
|
|
|
test("provider_model_allow: hides Claude Code from normal selection", () => {
|
|
const models = [
|
|
{ provider: "claude-code", id: "sonnet" },
|
|
{ provider: "kimi-coding", id: "kimi-for-coding" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(models, undefined);
|
|
|
|
assert.deepEqual(
|
|
filtered.map((m) => `${m.provider}/${m.id}`),
|
|
["kimi-coding/kimi-for-coding"],
|
|
);
|
|
});
|
|
|
|
test("provider_model_allow: hides Mistral non-selection endpoints", () => {
|
|
const models = [
|
|
{ provider: "mistral", id: "mistral-large-latest" },
|
|
{ provider: "mistral", id: "codestral-latest" },
|
|
{ provider: "mistral", id: "mistral-embed" },
|
|
{ provider: "mistral", id: "mistral-ocr-latest" },
|
|
{ provider: "mistral", id: "voxtral-mini-tts-latest" },
|
|
{ provider: "mistral", id: "ft:codestral-latest:abc" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(models, undefined);
|
|
|
|
assert.deepEqual(
|
|
filtered.map((m) => `${m.provider}/${m.id}`),
|
|
[
|
|
"mistral/mistral-large-latest",
|
|
"mistral/codestral-latest",
|
|
],
|
|
);
|
|
});
|
|
|
|
test("provider_model_block: blocks matching models even when provider is otherwise unrestricted", () => {
|
|
const models = [
|
|
{ provider: "minimax", id: "MiniMax-M2.7" },
|
|
{ provider: "minimax", id: "MiniMax-M2.7-highspeed" },
|
|
{ provider: "zai", id: "glm-5.1" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(models, undefined, {
|
|
minimax: ["MiniMax-M2.7-highspeed"],
|
|
});
|
|
|
|
assert.deepEqual(
|
|
filtered.map((m) => `${m.provider}/${m.id}`),
|
|
["minimax/MiniMax-M2.7", "zai/glm-5.1"],
|
|
);
|
|
});
|
|
|
|
test("provider_model_block: deny wins over provider_model_allow", () => {
|
|
const models = [
|
|
{ provider: "minimax", id: "MiniMax-M2.7" },
|
|
{ provider: "minimax", id: "MiniMax-M2.7-highspeed" },
|
|
];
|
|
|
|
const filtered = filterModelsByProviderModelAllow(
|
|
models,
|
|
{ minimax: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed"] },
|
|
{ minimax: ["MiniMax-M2.7"] },
|
|
);
|
|
|
|
assert.deepEqual(
|
|
filtered.map((m) => `${m.provider}/${m.id}`),
|
|
["minimax/MiniMax-M2.7-highspeed"],
|
|
);
|
|
});
|
|
|
|
test("blocked_providers: deny wins over allowed_providers", () => {
|
|
assert.equal(
|
|
isProviderAllowedByLists("zai", ["zai", "minimax"], ["zai"]),
|
|
false,
|
|
);
|
|
assert.equal(isProviderAllowedByLists("minimax", ["zai"], undefined), false);
|
|
assert.equal(isProviderAllowedByLists("minimax", undefined, ["zai"]), true);
|
|
});
|
|
|
|
test("provider_model_allow: validates shape and normalizes provider IDs", () => {
|
|
const result = validatePreferences({
|
|
provider_model_allow: {
|
|
MiniMax: [" MiniMax-M2.7 ", "MiniMax-M2.7-highspeed"],
|
|
},
|
|
provider_model_block: {
|
|
OpenRouter: [" z-ai/glm-5.1 ", ":paid"],
|
|
},
|
|
blocked_providers: [" OpenRouter ", "GOOGLE"],
|
|
});
|
|
|
|
assert.equal(result.errors.length, 0);
|
|
assert.deepEqual(result.preferences.provider_model_allow, {
|
|
minimax: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed"],
|
|
});
|
|
assert.deepEqual(result.preferences.provider_model_block, {
|
|
openrouter: ["z-ai/glm-5.1", ":paid"],
|
|
});
|
|
assert.deepEqual(result.preferences.blocked_providers, [
|
|
"openrouter",
|
|
"google",
|
|
]);
|
|
});
|
|
|
|
test("provider_model_allow: rejects invalid shapes", () => {
|
|
const result = validatePreferences({
|
|
provider_model_allow: {
|
|
minimax: "MiniMax-M2.7",
|
|
zai: ["glm-5", 42],
|
|
} as any,
|
|
provider_model_block: {
|
|
minimax: "MiniMax-M2.7",
|
|
} as any,
|
|
blocked_providers: ["openrouter", 7] as any,
|
|
});
|
|
|
|
assert.ok(
|
|
result.errors.some((error) =>
|
|
error.includes("provider_model_allow.minimax"),
|
|
),
|
|
);
|
|
assert.ok(
|
|
result.errors.some((error) => error.includes("provider_model_allow.zai")),
|
|
);
|
|
assert.ok(
|
|
result.errors.some((error) =>
|
|
error.includes("provider_model_block.minimax"),
|
|
),
|
|
);
|
|
assert.ok(
|
|
result.errors.some((error) =>
|
|
error.includes("blocked_providers must be an array of strings"),
|
|
),
|
|
);
|
|
assert.equal(result.preferences.provider_model_allow, undefined);
|
|
assert.equal(result.preferences.provider_model_block, undefined);
|
|
assert.equal(result.preferences.blocked_providers, undefined);
|
|
});
|
|
|
|
test("provider_model_allow: project overrides global per provider", () => {
|
|
const originalCwd = process.cwd();
|
|
const originalSfHome = process.env.SF_HOME;
|
|
const tempProject = mkdtempSync(
|
|
join(tmpdir(), "sf-provider-model-allow-project-"),
|
|
);
|
|
const tempHome = mkdtempSync(join(tmpdir(), "sf-provider-model-allow-home-"));
|
|
|
|
try {
|
|
mkdirSync(join(tempProject, ".sf"), { recursive: true });
|
|
|
|
writeFileSync(
|
|
join(tempHome, "preferences.md"),
|
|
[
|
|
"---",
|
|
"provider_model_allow:",
|
|
" minimax:",
|
|
" - MiniMax-M2.7",
|
|
" zai:",
|
|
" - glm-5",
|
|
"---",
|
|
].join("\n"),
|
|
"utf-8",
|
|
);
|
|
|
|
writeFileSync(
|
|
join(tempProject, ".sf", "PREFERENCES.md"),
|
|
[
|
|
"---",
|
|
"provider_model_allow:",
|
|
" minimax:",
|
|
" - MiniMax-M2.7-highspeed",
|
|
"---",
|
|
].join("\n"),
|
|
"utf-8",
|
|
);
|
|
|
|
process.env.SF_HOME = tempHome;
|
|
process.chdir(tempProject);
|
|
|
|
const loaded = loadEffectiveSFPreferences();
|
|
assert.notEqual(loaded, null);
|
|
assert.deepEqual(loaded!.preferences.provider_model_allow, {
|
|
minimax: ["MiniMax-M2.7-highspeed"],
|
|
zai: ["glm-5"],
|
|
});
|
|
} finally {
|
|
process.chdir(originalCwd);
|
|
if (originalSfHome === undefined) delete process.env.SF_HOME;
|
|
else process.env.SF_HOME = originalSfHome;
|
|
rmSync(tempProject, { recursive: true, force: true });
|
|
rmSync(tempHome, { recursive: true, force: true });
|
|
}
|
|
});
|