Files
openrusrouter-mcp/test/pick-model.test.ts
T
vasyanskandClaude Opus 5.5 d7d02267e2 Добавь HTTP-клиент, каталог моделей и шесть read-only инструментов MCP
Клиент — замороженный объект с семью методами GET, без универсального
запроса и тела; денежные поля проверяются на строковый тип (fail-closed).
Каталог джойнит /api/models и /v1/models: модальность и рубли из первого,
USD и доступность ключу из второго. Инструменты: list_models, pick_model,
estimate_cost, get_balance, get_usage, list_api_keys. Точка входа stdio
уводит console.log/info в stderr первым импортом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 10:59:06 +07:00

123 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});