Добавь 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>
This commit is contained in:
vasyanskandClaude Opus 5.5 committed 2026-10-24 10:59:06 +07:00
1 parent ade6eb82df
commit d7d02267e2
18 files changed
+1935

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
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);
});