Клиент — замороженный объект с семью методами 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>
123 lines
5.9 KiB
TypeScript
123 lines
5.9 KiB
TypeScript
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);
|
||
});
|