import { test } from "node:test"; import assert from "node:assert/strict"; import { WHY, filterModels, rankModels, sortModels } from "../src/tools/models.js"; import { byId, fixtureCatalog, synthetic } from "./helpers.js"; const items = fixtureCatalog(); test("фильтр по модальности: decision не смешивается с text", () => { const text = filterModels(items, { modality: "text" }); assert.ok(text.length > 0); assert.ok(text.every((m) => m.modality === "text")); assert.ok(!text.some((m) => m.id.startsWith("typesafe/"))); const decision = filterModels(items, { modality: "decision" }).map((m) => m.id); assert.deepEqual(decision.sort(), ["typesafe/jev-1.13.0", "typesafe/jev-latest"]); }); test("vision: только модели с image во входных модальностях", () => { const ids = filterModels(items, { modality: "text", vision: true }).map((m) => m.id).sort(); assert.deepEqual(ids, ["anthropic/claude-sonnet-5.5", "nvidia/nemotron-3.5-super-vl:free", "openai/gpt-6-luna"]); }); test("min_context отсекает малые и неизвестные контексты", () => { const ids = filterModels(items, { modality: "text", min_context: 1_000_000 }).map((m) => m.id).sort(); assert.deepEqual(ids, [ "anthropic/claude-sonnet-5.5", "deepseek/deepseek-v4-flash", "nvidia/nemotron-3.5-super-vl:free", "openai/gpt-6-luna", ]); // У transcription контекст неизвестен (null) — не проходит любой min_context > 0. assert.equal(filterModels(items, { modality: "transcription", min_context: 1 }).length, 0); }); test("max-цена сравнивается как decimal, а не лексикографически", () => { // Лексикографически "79.91392973" < "9" и "10.38881086" < "9" — модели прошли бы. const ids = filterModels(items, { modality: "text", max_prompt_rub_1m: "9" }).map((m) => m.id).sort(); assert.deepEqual(ids, ["nvidia/nemotron-3.5-super-vl:free", "qwen/qwen3-coder-30b-a3b-instruct"]); const synth = [synthetic("x/ten", "10"), synthetic("x/nine-half", "9.5")]; assert.deepEqual( filterModels(synth, { max_prompt_rub_1m: "9.75" }).map((m) => m.id), ["x/nine-half"], ); assert.deepEqual( sortModels(synth, "price").map((m) => m.id), ["x/nine-half", "x/ten"], ); }); test("max-цена отсекает модели с неизвестной ценой; decision проходит max_completion", () => { assert.equal(filterModels(items, { modality: "image", max_prompt_rub_1m: "1000" }).length, 0); const dec = filterModels(items, { modality: "decision", max_completion_rub_1m: "0" }); assert.equal(dec.length, 2); }); test("недоступные ключу модели в подборе не участвуют", () => { const restricted = items.map((m) => m.id === "qwen/qwen3-coder-30b-a3b-instruct" ? { ...m, allowed_for_this_key: false } : m, ); assert.ok(!filterModels(restricted, { modality: "text" }).some((m) => m.id.startsWith("qwen/"))); assert.ok(filterModels(restricted, { modality: "text", include_disallowed: true }).some((m) => m.id.startsWith("qwen/"))); }); test("cheapest без объёма — по цене входа, decimal-порядок, null в конце", () => { const ranked = rankModels(filterModels(items, { modality: "text" }), { prefer: "cheapest" }); assert.deepEqual( ranked.map((m) => m.id), [ "nvidia/nemotron-3.5-super-vl:free", // 0 "qwen/qwen3-coder-30b-a3b-instruct", // 7.99… "deepseek/deepseek-v4-flash", // 10.38… (лексикографически встал бы до 7.99) "openai/gpt-6-luna", // 11.41… "aion-labs/aion-3.0-mini", // 79.91… "anthropic/claude-sonnet-5.5", // 228.32… ], ); assert.equal(ranked[0]?.why, WHY.cheapestPrompt); assert.equal(ranked[1]?.why, WHY.nextPrompt); assert.ok(ranked.every((m) => m.est_cost_rub === null)); const withNull = rankModels([synthetic("a/none", null), synthetic("b/cheap", "1")], { prefer: "cheapest" }); assert.deepEqual(withNull.map((m) => m.id), ["b/cheap", "a/none"]); assert.equal(withNull[1]?.why, WHY.noPrice); }); test("детерминированный tie-break по id при равной цене", () => { const dec = filterModels(items, { modality: "decision" }); for (const input of [dec, [...dec].reverse()]) { assert.deepEqual( rankModels(input, { prefer: "cheapest" }).map((m) => m.id), ["typesafe/jev-1.13.0", "typesafe/jev-latest"], ); } }); test("cheapest с объёмом токенов — по оценке, а не по цене входа", () => { // qwen: 7.99139297 вход / 31.96557189 выход; deepseek: 10.38881086 / 20.77762173. // При 100k входа и 1M выхода: qwen = 0.799139297 + 31.96557189 = 32.764711187, // deepseek = 1.038881086 + 20.77762173 = 21.816502816 — deepseek дешевле. const pair = [byId(items, "qwen/qwen3-coder-30b-a3b-instruct"), byId(items, "deepseek/deepseek-v4-flash")]; const ranked = rankModels(pair, { prefer: "cheapest", expected_prompt_tokens: 100_000, expected_completion_tokens: 1_000_000, }); assert.deepEqual(ranked.map((m) => m.id), ["deepseek/deepseek-v4-flash", "qwen/qwen3-coder-30b-a3b-instruct"]); assert.equal(ranked[0]?.est_cost_rub, "21.82"); assert.equal(ranked[1]?.est_cost_rub, "32.76"); assert.equal(ranked[0]?.why, WHY.cheapestEstimate); }); test("largest_context — по убыванию контекста, равные по id", () => { const ranked = rankModels(filterModels(items, { modality: "text" }), { prefer: "largest_context" }); // 1050000 > 1048576 > 1000000 = 1000000 (равные — по id: "anthropic/" < "nvidia/"). assert.deepEqual(ranked.slice(0, 4).map((m) => m.id), [ "openai/gpt-6-luna", "deepseek/deepseek-v4-flash", "anthropic/claude-sonnet-5.5", "nvidia/nemotron-3.5-super-vl:free", ]); assert.equal(ranked[0]?.why, WHY.largestContext); });