Добавь 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

+50
View File
@@ -0,0 +1,50 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import type { ApiKeyEntry, UsageResponse } from "../src/client.js";
import { FAILED_NOTE, summarizeUsage, usageWindow } from "../src/tools/billing.js";
import { KEYS_TOOL_DESCRIPTION, presentKeys } from "../src/tools/keys.js";
import { fixture } from "./helpers.js";
test("usageWindow: to = сейчас, from = сейчас − days, без миллисекунд", () => {
const w = usageWindow(7, new Date("2026-10-24T03:39:57.123Z"));
assert.deepEqual(w, { from: "2026-10-17T03:39:57Z", to: "2026-10-24T03:39:57Z" });
});
test("summarizeUsage: total по всем строкам до обрезки, decimal-сложение", () => {
const resp = fixture<UsageResponse>("api-usage");
const s = summarizeUsage(resp, 2);
// 0.8124417 + 0.18412076 + 0.24310558 + 0.05120934 + 0.00371284 = 1.29459022
assert.equal(s.total_rub, "1.29459022");
assert.equal((s.items as unknown[]).length, 2);
assert.equal(s.truncated, true);
assert.equal(s.failed_note, null);
const failed = summarizeUsage({ ...resp, failed_events: 3 }, 50);
assert.equal(failed.failed_note, FAILED_NOTE);
assert.equal(failed.truncated, false);
});
test("presentKeys: ключ не выводится, текущий отмечен по префиксу, отозванные скрыты", () => {
const { keys } = fixture<{ keys: ApiKeyEntry[] }>("api-keys");
const current = "orr_live_Gh5iJkSECRETSECRETSECRETSECRET1";
const withRevoked = [...keys, { ...(keys[0] as ApiKeyEntry), id: "rev", revoked_at: "2026-10-01T00:00:00Z" }];
const out = presentKeys(withRevoked, current, false);
assert.equal(out.length, 3);
assert.deepEqual(out.map((k) => k.is_current_key), [false, true, false]);
assert.ok(!JSON.stringify(out).includes(current));
assert.equal(out[0]?.all_models_allowed, true);
assert.equal(out[0]?.allowed_models, null);
assert.deepEqual(out[1]?.allowed_models, ["openai/gpt-4o-mini"]);
assert.equal(out[1]?.all_models_allowed, false);
assert.equal(out[0]?.spend_used_rub, "12.5784961");
assert.equal(out[0]?.key_prefix, "orr_live_Ab3dEf");
assert.equal(presentKeys(withRevoked, current, true).length, 4);
});
test("описание list_api_keys дословно говорит о запрете управления ключами", () => {
assert.ok(
KEYS_TOOL_DESCRIPTION.includes(
"Создать, отозвать или изменить ключ через MCP нельзя — такие действия требуют браузерной сессии на openrusrouter.ru. " +
"Это сознательное ограничение безопасности: ключ, которым пользуется MCP, не должен уметь управлять другими ключами или собой",
),
);
});
+105
View File
@@ -0,0 +1,105 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildCatalog, joinCatalog } from "../src/catalog.js";
import type { ApiModelsResponse, Client, V1ModelsResponse } from "../src/client.js";
import { byId, fixture, fixtureCatalog } from "./helpers.js";
const api = fixture<ApiModelsResponse>("api-models");
const v1 = fixture<V1ModelsResponse>("v1-models");
test("transcription и speech: пометка вместо цены, токенные цены null", () => {
const items = fixtureCatalog();
for (const [id, note] of [
["openai/whisper-1", "тарифицируется по факту распознавания"],
["assemblyai/universal-3-5-pro", "тарифицируется по факту распознавания"],
["elevenlabs/eleven-flash-v2", "тарифицируется по факту синтеза"],
] as const) {
const m = byId(items, id);
assert.equal(m.price_note, note, id);
assert.equal(m.price_prompt_rub_1m, null, id);
assert.equal(m.price_completion_rub_1m, null, id);
assert.equal(m.context_length, null, id);
}
});
test("image: цена с единицей на русском; без единицы — «цена уточняется»", () => {
const items = fixtureCatalog();
const seedream = byId(items, "bytedance-seed/seedream-4.5");
assert.equal(seedream.price_note, "4.56651027 ₽ за изображение");
assert.equal(seedream.price_image_unit, "image");
assert.equal(seedream.price_prompt_rub_1m, null);
const gemini = byId(items, "google/gemini-2.5-flash-image");
// единица token у бэкенда — цена за 1M токенов, не за один
assert.equal(gemini.price_note, "3424.8827025 ₽ за 1M токенов");
const flux = byId(items, "black-forest-labs/flux-3-image");
assert.equal(flux.price_note, "цена уточняется");
assert.equal(flux.price_image_rub, null);
assert.equal(flux.price_image_unit, null);
});
test("decision: модальность из /api/models, completion-цена null", () => {
const items = fixtureCatalog();
const jev = byId(items, "typesafe/jev-1.13.0");
assert.equal(jev.modality, "decision"); // /v1/models называет её text->text
assert.equal(jev.price_completion_rub_1m, null);
assert.equal(jev.price_prompt_rub_1m, "4.79483578");
assert.equal(jev.price_note, null);
});
test("text: рублёвые и USD-цены, vendor, allowed_for_this_key", () => {
const items = fixtureCatalog();
assert.equal(items.length, api.data.length);
assert.ok(items.every((m) => m.allowed_for_this_key));
const ds = byId(items, "deepseek/deepseek-v4-flash");
assert.equal(ds.vendor, "deepseek");
assert.equal(ds.price_prompt_rub_1m, "10.38881086");
assert.deepEqual(ds.usd, {
prompt: "0.0000001289925",
completion: "0.000000257985",
input_cache_read: "0.0000000257985",
});
assert.equal(byId(items, "qwen/qwen3-coder-30b-a3b-instruct").usd?.input_cache_read, null);
assert.equal(byId(items, "openai/whisper-1").usd, null);
assert.equal(ds.price_note, null);
});
test("модель только в /api/models — недоступна ключу; только в /v1/models — исключена с одной строкой в лог", () => {
const logs: string[] = [];
const v1Restricted = v1.data.filter((m) => m.id !== "anthropic/claude-sonnet-5.5");
const extraV1 = [...v1Restricted, { id: "ghost/model-a" }, { id: "ghost/model-b" }];
const items = joinCatalog(api.data, extraV1, (m) => logs.push(m));
assert.equal(byId(items, "anthropic/claude-sonnet-5.5").allowed_for_this_key, false);
assert.equal(byId(items, "anthropic/claude-sonnet-5.5").usd, null);
assert.equal(items.find((m) => m.id.startsWith("ghost/")), undefined);
assert.equal(logs.length, 1);
assert.match(logs[0] ?? "", /2 моделей разошлись/);
});
test("описания чистятся от управляющих и bidi-символов", () => {
const items = joinCatalog(
[{ ...(api.data[0] as ApiModelsResponse["data"][number]), name: "Evil‮name", description: "a\u0007b​c" }],
v1.data,
() => undefined,
);
assert.equal(items[0]?.name, "Evilname");
assert.equal(items[0]?.description, "abc");
});
test("buildCatalog тянет оба листинга параллельно", async () => {
let inflight = 0;
let maxInflight = 0;
const track = async <T>(v: T): Promise<T> => {
inflight += 1;
maxInflight = Math.max(maxInflight, inflight);
await new Promise((r) => setTimeout(r, 5));
inflight -= 1;
return v;
};
const client = {
getApiModels: () => track(api),
getV1Models: () => track(v1),
} as unknown as Client;
const items = await buildCatalog(client);
assert.equal(items.length, api.data.length);
assert.equal(maxInflight, 2);
});
+198
View File
@@ -0,0 +1,198 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { ApiError } from "../src/errors.js";
import { ALLOWED_PATHS, createClient } from "../src/client.js";
import { fixture } from "./helpers.js";
const BASE = "https://example.test";
const KEY = "orr_live_TESTKEY0000000000000000000000000";
interface Call {
url: URL;
init: RequestInit | undefined;
}
const FIXTURE_BY_PATH: Record<string, string> = {
"/v1/models": "v1-models",
"/api/models": "api-models",
"/api/keys/models": "keys-models",
"/api/balance": "api-balance",
"/api/usage": "api-usage",
"/api/keys": "api-keys",
"/v1/auth/key": "v1-auth-key",
};
function fakeFetch(override?: (url: URL) => Response | undefined): { fetchImpl: typeof fetch; calls: Call[] } {
const calls: Call[] = [];
const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, init });
const custom = override?.(url);
if (custom !== undefined) return custom;
const name = FIXTURE_BY_PATH[url.pathname];
if (name === undefined) return new Response("{}", { status: 404 });
return new Response(JSON.stringify(fixture(name)), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return { fetchImpl, calls };
}
const METHODS = [
"getV1Models",
"getApiModels",
"getKeyModels",
"getBalance",
"getUsage",
"getKeys",
"getAuthKey",
] as const;
test("клиент — замороженный объект ровно с семью методами чтения", () => {
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fakeFetch().fetchImpl);
assert.deepEqual(Object.keys(client).sort(), [...METHODS].sort());
assert.ok(Object.isFrozen(client));
// Модули ESM — strict mode: запись в замороженный объект бросает TypeError.
assert.throws(() => {
(client as unknown as Record<string, unknown>).request = () => undefined;
}, TypeError);
assert.throws(() => {
(client as unknown as Record<string, unknown>).getBalance = () => undefined;
}, TypeError);
assert.equal("request" in client, false);
});
test("модуль экспортирует только createClient и ALLOWED_PATHS", async () => {
const mod = await import("../src/client.js");
assert.deepEqual(Object.keys(mod).sort(), ["ALLOWED_PATHS", "createClient"]);
assert.equal(ALLOWED_PATHS.length, 7);
});
test("каждый метод шлёт GET без тела на путь из ALLOWED_PATHS с нужными заголовками", async () => {
const { fetchImpl, calls } = fakeFetch();
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await client.getV1Models();
await client.getApiModels();
await client.getKeyModels();
await client.getBalance();
await client.getUsage({ from: "2026-10-17T00:00:00Z", to: "2026-10-24T00:00:00Z", groupBy: "model" });
await client.getKeys();
await client.getAuthKey();
assert.equal(calls.length, 7);
for (const c of calls) {
assert.equal(c.init?.method, "GET");
assert.equal(c.init?.body, undefined);
assert.equal(c.init?.redirect, "error");
assert.ok(c.init?.signal instanceof AbortSignal);
assert.ok(ALLOWED_PATHS.includes(c.url.pathname), c.url.pathname);
assert.equal(c.url.origin, BASE);
const h = c.init?.headers as Record<string, string>;
assert.equal(h.Authorization, `Bearer ${KEY}`);
assert.equal(h.Accept, "application/json");
assert.match(h["User-Agent"] ?? "", /^openrusrouter-mcp\/\d+\.\d+\.\d+/);
}
assert.deepEqual(new Set(calls.map((c) => c.url.pathname)), new Set(ALLOWED_PATHS));
const usage = calls.find((c) => c.url.pathname === "/api/usage");
assert.equal(usage?.url.searchParams.get("group_by"), "model");
assert.equal(usage?.url.searchParams.get("from"), "2026-10-17T00:00:00Z");
});
test("исходник client.ts не содержит мутирующих HTTP-методов, fetch( вызывается только в нём", () => {
const srcDir = new URL("../src/", import.meta.url);
const own = readFileSync(new URL("client.ts", srcDir), "utf8");
for (const m of ["POST", "PUT", "PATCH", "DELETE"]) {
assert.ok(!own.includes(`"${m}"`) && !own.includes(`'${m}'`) && !own.includes(`\`${m}\``), m);
}
const files = readdirSync(srcDir, { recursive: true, encoding: "utf8" }).filter(
(f) => f.endsWith(".ts") && !f.startsWith("generated"),
);
assert.ok(files.length > 5);
for (const f of files) {
if (f === "client.ts") continue;
const text = readFileSync(new URL(f, srcDir), "utf8");
assert.ok(!/\bfetch\s*\(/.test(text), `вызов fetch( в src/${f}`);
}
});
test("денежное поле числом — fail-closed ошибка, а не String(number)", async () => {
const { fetchImpl } = fakeFetch((url) =>
url.pathname === "/api/balance"
? new Response(JSON.stringify({ balance: 1234.56, referral_balance: "0" }), { status: 200 })
: undefined,
);
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await assert.rejects(client.getBalance(), /неожиданный числовой тип у денежного поля \/api\/balance\.balance/);
});
test("числовая сумма в ключе и в /v1/auth/key тоже отвергается", async () => {
const { fetchImpl } = fakeFetch((url) => {
if (url.pathname === "/api/keys") {
const body = fixture<{ keys: Record<string, unknown>[] }>("api-keys");
(body.keys[0] as Record<string, unknown>).spend_used = 12.5;
return new Response(JSON.stringify(body), { status: 200 });
}
if (url.pathname === "/v1/auth/key") {
return new Response(JSON.stringify({ data: { ...fixture<{ data: object }>("v1-auth-key").data, usage: 1 } }), {
status: 200,
});
}
return undefined;
});
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await assert.rejects(client.getKeys(), /spend_used/);
await assert.rejects(client.getAuthKey(), /usage/);
});
test("401 с телом ошибки — ApiError со статусом, кодом и путём", async () => {
const { fetchImpl } = fakeFetch(
() =>
new Response(JSON.stringify({ error: { code: "invalid_key", message: `bad key ${KEY} ${"x".repeat(500)}` } }), {
status: 401,
}),
);
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
const err = await client.getBalance().then(
() => assert.fail("ожидалась ошибка"),
(e: unknown) => e,
);
assert.ok(err instanceof ApiError);
assert.equal(err.status, 401);
assert.equal(err.code, "invalid_key");
assert.equal(err.path, "/api/balance");
assert.ok(Array.from(err.message).length <= 300);
assert.ok(!err.message.includes(KEY));
});
test("сетевая ошибка оборачивается без cause и с вычищенным ключом", async () => {
const fetchImpl = (async () => {
const e = new TypeError(`connect failed, Authorization: Bearer ${KEY}`);
(e as unknown as { cause: unknown }).cause = { headers: { Authorization: `Bearer ${KEY}` } };
throw e;
}) as typeof fetch;
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
const err = await client.getBalance().then(
() => assert.fail("ожидалась ошибка"),
(e: unknown) => e as Error,
);
assert.ok(!err.message.includes(KEY));
assert.match(err.message, /TypeError/);
assert.equal(err.cause, undefined);
});
test("ответ с Content-Length больше 5 МБ отвергается до чтения тела", async () => {
const { fetchImpl } = fakeFetch(
() => new Response("{}", { status: 200, headers: { "content-length": "5000001" } }),
);
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await assert.rejects(client.getBalance(), /слишком велик/);
});
test("/api/keys без обёртки keys — ошибка формы", async () => {
const { fetchImpl } = fakeFetch((url) =>
url.pathname === "/api/keys" ? new Response(JSON.stringify([]), { status: 200 }) : undefined,
);
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await assert.rejects(client.getKeys(), /неожиданная форма ответа/);
});
+66
View File
@@ -0,0 +1,66 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { estimateCost } from "../src/tools/models.js";
import { byId, fixtureCatalog } from "./helpers.js";
const items = fixtureCatalog();
test("image, transcription, speech — явный отказ, а не ноль", () => {
for (const id of [
"bytedance-seed/seedream-4.5",
"black-forest-labs/flux-3-image",
"openai/whisper-1",
"elevenlabs/eleven-flash-v2",
]) {
const r = estimateCost(byId(items, id), { prompt_tokens: 1000, completion_tokens: 0 });
assert.equal(r.ok, false, id);
if (!r.ok) assert.match(r.error, /нет цены за токен — тарификация по факту, оценка невозможна/);
}
});
test("text-модель: рубли без кэша, USD с ценой кэш-чтения", () => {
const ds = byId(items, "deepseek/deepseek-v4-flash");
// ₽: 1_000_000 × 10.38881086 / 1e6 = 10.38881086
// 500_000 × 20.77762173 / 1e6 = 10.388810865
// итого 20.777621725 → 20.78
// $: (1_000_000 − 400_000) × 0.0000001289925 = 0.0773955
// 400_000 × 0.0000000257985 = 0.0103194
// 500_000 × 0.000000257985 = 0.1289925
// итого 0.2167074
const r = estimateCost(ds, { prompt_tokens: 1_000_000, completion_tokens: 500_000, cached_tokens: 400_000 });
assert.ok(r.ok);
assert.equal(r.cost_rub, "20.777621725");
assert.equal(r.cost_rub_rounded, "20.78");
assert.equal(r.cost_usd, "0.2167074");
assert.match(r.basis, /без учёта скидки за кэш/);
assert.match(r.basis, /цене кэш-чтения/);
const noCache = estimateCost(ds, { prompt_tokens: 1_000_000, completion_tokens: 500_000 });
assert.ok(noCache.ok);
// $: 0.1289925 + 0.1289925 = 0.257985
assert.equal(noCache.cost_usd, "0.257985");
assert.equal(noCache.cost_rub, "20.777621725");
});
test("модель без объявленной цены кэша считает кэш по полной цене и говорит об этом", () => {
const qwen = byId(items, "qwen/qwen3-coder-30b-a3b-instruct");
// $: 1000 × 0.000000099225 = 0.000099225 (кэш по полной цене)
const r = estimateCost(qwen, { prompt_tokens: 1000, cached_tokens: 500 });
assert.ok(r.ok);
assert.equal(r.cost_usd, "0.000099225");
assert.match(r.basis, /не объявляет цену кэш-чтения/);
});
test("decision: выход не тарифицируется", () => {
const jev = byId(items, "typesafe/jev-1.13.0");
// 2_000_000 × 4.79483578 / 1e6 = 9.58967156; выход бесплатен.
const r = estimateCost(jev, { prompt_tokens: 2_000_000, completion_tokens: 10 });
assert.ok(r.ok);
assert.equal(r.cost_rub, "9.58967156");
assert.match(r.basis, /выход decision-модели не тарифицируется/);
});
test("кэш больше промпта — ошибка", () => {
const r = estimateCost(byId(items, "deepseek/deepseek-v4-flash"), { prompt_tokens: 10, cached_tokens: 11 });
assert.equal(r.ok, false);
});
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs";
import { joinCatalog, type CatalogItem } from "../src/catalog.js";
import type { ApiModelsResponse, V1ModelsResponse } from "../src/client.js";
export function fixture<T = unknown>(name: string): T {
return JSON.parse(readFileSync(new URL(`./fixtures/${name}.json`, import.meta.url), "utf8")) as T;
}
export function fixtureCatalog(): CatalogItem[] {
const api = fixture<ApiModelsResponse>("api-models");
const v1 = fixture<V1ModelsResponse>("v1-models");
return joinCatalog(api.data, v1.data, () => undefined);
}
export function byId(items: readonly CatalogItem[], id: string): CatalogItem {
const it = items.find((m) => m.id === id);
if (it === undefined) throw new Error(`нет ${id} в фикстуре`);
return it;
}
/** Синтетическая текстовая модель для граничных случаев сравнения цен. */
export function synthetic(id: string, prompt: string | null, completion: string | null = prompt): CatalogItem {
return {
id,
name: id,
description: "",
vendor: id.split("/")[0] ?? id,
modality: "text",
input_modalities: ["text"],
context_length: 100_000,
is_free: false,
price_prompt_rub_1m: prompt,
price_completion_rub_1m: completion,
price_image_rub: null,
price_image_unit: null,
price_note: null,
usd: null,
allowed_for_this_key: true,
};
}
+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);
});
+78
View File
@@ -0,0 +1,78 @@
// Собранный сервер отвечает на initialize и tools/list, а в stdout не
// пишет ничего, кроме JSON-RPC. В сеть не ходит: tools/list её не требует.
import { test } from "node:test";
import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("..", import.meta.url));
test("dist/index.js: stdout — только валидный JSON-RPC, шесть read-only инструментов", { timeout: 60_000 }, async () => {
execFileSync(process.execPath, ["node_modules/typescript/bin/tsc", "-p", "tsconfig.build.json"], {
cwd: root,
stdio: "inherit",
});
const child = spawn(process.execPath, ["dist/index.js"], {
cwd: root,
env: { PATH: process.env.PATH ?? "", OPENRUSROUTER_API_KEY: "test", OPENRUSROUTER_BASE_URL: "http://localhost:1" },
stdio: ["pipe", "pipe", "pipe"],
});
const lines: string[] = [];
let buf = "";
const waiters = new Map<number, (msg: Record<string, unknown>) => void>();
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
buf += chunk;
let i: number;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 1);
lines.push(line);
try {
const msg = JSON.parse(line) as Record<string, unknown>;
if (typeof msg.id === "number") waiters.get(msg.id)?.(msg);
} catch {
// невалидная строка будет поймана проверкой ниже
}
}
});
child.stderr.resume();
const send = (msg: object) => child.stdin.write(`${JSON.stringify(msg)}\n`);
const request = (id: number, method: string, params: object) =>
new Promise<Record<string, unknown>>((resolve) => {
waiters.set(id, resolve);
send({ jsonrpc: "2.0", id, method, params });
});
try {
const init = await request(1, "initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "test", version: "0" },
});
assert.equal((init.result as { serverInfo: { name: string } }).serverInfo.name, "openrusrouter");
send({ jsonrpc: "2.0", method: "notifications/initialized" });
const list = await request(2, "tools/list", {});
const tools = (list.result as { tools: { name: string; annotations?: Record<string, unknown> }[] }).tools;
assert.deepEqual(
tools.map((t) => t.name).sort(),
["estimate_cost", "get_balance", "get_usage", "list_api_keys", "list_models", "pick_model"],
);
for (const t of tools) {
assert.equal(t.annotations?.readOnlyHint, true, t.name);
assert.equal(t.annotations?.destructiveHint, false, t.name);
}
} finally {
child.kill();
}
assert.ok(lines.length >= 2);
for (const line of lines) {
assert.doesNotThrow(() => JSON.parse(line), `не JSON в stdout: ${line.slice(0, 100)}`);
}
});
+100
View File
@@ -0,0 +1,100 @@
// Вызов инструментов через настоящий MCP-протокол (in-memory транспорт) поверх
// клиента с фейковым fetch на фикстурах: проверяет связку zod-схем, дефолтов,
// кэша каталога и формы результатов.
import { test } from "node:test";
import assert from "node:assert/strict";
import { Client as McpClient } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createTtlCache } from "../src/cache.js";
import { CATALOG_TTL_MS, buildCatalog } from "../src/catalog.js";
import { createClient } from "../src/client.js";
import { registerAllTools } from "../src/tools/index.js";
import { fixture } from "./helpers.js";
const KEY = "orr_live_Ab3dEfSECRETSECRETSECRETSECRET";
const FIXTURES: Record<string, string> = {
"/v1/models": "v1-models",
"/api/models": "api-models",
"/api/balance": "api-balance",
"/api/usage": "api-usage",
"/api/keys": "api-keys",
};
async function setup(status = 200) {
const counts: Record<string, number> = {};
const fetchImpl = (async (input: string | URL | Request) => {
const path = new URL(String(input)).pathname;
counts[path] = (counts[path] ?? 0) + 1;
if (status !== 200) return new Response(JSON.stringify({ error: { code: "invalid_key" } }), { status });
return new Response(JSON.stringify(fixture(FIXTURES[path] ?? "missing")), { status: 200 });
}) as typeof fetch;
const client = createClient({ apiKey: KEY, baseUrl: "https://example.test" }, fetchImpl);
const server = new McpServer({ name: "openrusrouter", version: "0.0.0" });
registerAllTools(server, {
client,
catalog: createTtlCache(CATALOG_TTL_MS, () => buildCatalog(client)),
apiKey: KEY,
});
const [a, b] = InMemoryTransport.createLinkedPair();
const mcp = new McpClient({ name: "t", version: "0" });
await Promise.all([server.connect(a), mcp.connect(b)]);
return { mcp, counts };
}
type Result = { isError?: boolean; structuredContent?: Record<string, unknown>; content: { text: string }[] };
async function call(mcp: McpClient, name: string, args: Record<string, unknown> = {}): Promise<Result> {
return (await mcp.callTool({ name, arguments: args })) as unknown as Result;
}
test("list_models с дефолтами: только text, по цене, каталог кэшируется", async () => {
const { mcp, counts } = await setup();
const r = await call(mcp, "list_models");
assert.notEqual(r.isError, true);
const sc = r.structuredContent as { total: number; items: { id: string; modality: string }[] };
assert.equal(sc.total, 6);
assert.ok(sc.items.every((m) => m.modality === "text"));
assert.equal(sc.items[0]?.id, "nvidia/nemotron-3.5-super-vl:free");
assert.deepEqual(JSON.parse(r.content[0]?.text ?? ""), sc);
await call(mcp, "list_models", { modality: "image", limit: 1, offset: 1 });
assert.equal(counts["/api/models"], 1);
assert.equal(counts["/v1/models"], 1);
});
test("pick_model и estimate_cost", async () => {
const { mcp } = await setup();
const p = await call(mcp, "pick_model", { max_prompt_rub_1m: "9", limit: 1 });
const items = (p.structuredContent as { items: { id: string; why: string }[] }).items;
assert.equal(items.length, 1);
assert.equal(items[0]?.id, "nvidia/nemotron-3.5-super-vl:free");
const e = await call(mcp, "estimate_cost", { model_id: "openai/whisper-1", prompt_tokens: 10 });
assert.equal(e.isError, true);
const missing = await call(mcp, "estimate_cost", { model_id: "no/such", prompt_tokens: 10 });
assert.equal(missing.isError, true);
assert.match(missing.content[0]?.text ?? "", /не найдена в каталоге/);
});
test("get_balance, get_usage, list_api_keys", async () => {
const { mcp } = await setup();
const b = (await call(mcp, "get_balance")).structuredContent ?? {};
assert.equal(b.balance_rub, "1234.56789012");
assert.equal(b.referral_balance_rub, "0");
const u = (await call(mcp, "get_usage", { days: 7 })).structuredContent ?? {};
assert.equal(u.total_rub, "1.29459022");
const k = await call(mcp, "list_api_keys");
assert.ok(!(k.content[0]?.text ?? "").includes(KEY));
const keys = (k.structuredContent as { keys: { is_current_key: boolean }[] }).keys;
assert.deepEqual(keys.map((x) => x.is_current_key), [true, false, false]);
});
test("ошибка API превращается в isError без утечки ключа", async () => {
const { mcp } = await setup(401);
const r = await call(mcp, "get_balance");
assert.equal(r.isError, true);
assert.match(r.content[0]?.text ?? "", /ключ недействителен/);
assert.ok(!(r.content[0]?.text ?? "").includes(KEY));
});