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

+182
View File
@@ -0,0 +1,182 @@
// Каталог моделей: джойн /api/models (рублёвые цены с наценкой аккаунта и
// истинная модальность) и /v1/models (USD-цены и фильтр allowed_models ключа).
//
// Модальность берётся ТОЛЬКО из /api/models: у decision-моделей /v1/models
// показывает "text->text". Доступность ключу — по наличию в /v1/models: этот
// листинг бэкенд фильтрует по allowed_models ключа, а /api/models — нет
// (proxy.handleModelsAPI не вызывает modelAllowed). Поэтому модель, которая
// есть только в /api/models, — не расхождение, а недоступная ключу модель.
import type { ApiModel, Client, V1Model } from "./client.js";
import { parseDecimal } from "./money.js";
import { cleanText } from "./sanitize.js";
export const MODALITIES = ["text", "image", "transcription", "speech", "embedding", "decision"] as const;
export type Modality = (typeof MODALITIES)[number];
export type ImageUnit = "image" | "token" | "megapixel";
export interface CatalogItem {
id: string;
name: string;
description: string;
vendor: string;
modality: Modality;
input_modalities: string[];
context_length: number | null;
is_free: boolean;
price_prompt_rub_1m: string | null;
price_completion_rub_1m: string | null;
price_image_rub: string | null;
price_image_unit: ImageUnit | null;
price_note: string | null;
usd: { prompt: string; completion: string; input_cache_read: string | null } | null;
allowed_for_this_key: boolean;
}
/** Источник каталога (обычно TTL-кэш поверх buildCatalog, создаётся в index.ts). */
export type CatalogSource = () => Promise<CatalogItem[]>;
export const CATALOG_TTL_MS = 10 * 60 * 1000;
const IMAGE_UNIT_RU: Record<ImageUnit, string> = {
image: "изображение",
// Для единицы token бэкенд приводит цену к миллиону токенов
// (proxy.imagePriceRUB → PricePerMillionRUB), а не отдаёт за один токен.
token: "1M токенов",
megapixel: "мегапиксель",
};
function isModality(v: unknown): v is Modality {
return typeof v === "string" && (MODALITIES as readonly string[]).includes(v);
}
function isImageUnit(v: unknown): v is ImageUnit {
return v === "image" || v === "token" || v === "megapixel";
}
/** Decimal-строка или null; мусор (не строка, экспонента) — null, а не ноль. */
function money(v: unknown): string | null {
if (typeof v !== "string") return null;
try {
parseDecimal(v);
return v;
} catch {
return null;
}
}
function isPositive(v: string | null): v is string {
return v !== null && /[1-9]/.test(v);
}
function contextLength(api: ApiModel, v1: V1Model | undefined): number | null {
for (const c of [api.context_length, v1?.context_length]) {
if (typeof c === "number" && Number.isSafeInteger(c) && c > 0) return c;
}
return null;
}
function usdOf(v1: V1Model | undefined): CatalogItem["usd"] {
const p = v1?.pricing;
if (p === undefined) return null;
const prompt = money(p.prompt);
const completion = money(p.completion);
if (prompt === null || completion === null) return null;
return { prompt, completion, input_cache_read: money(p.input_cache_read) };
}
function buildItem(api: ApiModel, v1: V1Model | undefined, modality: Modality): CatalogItem {
let pricePrompt = money(api.price_prompt_rub_1m);
let priceCompletion = money(api.price_completion_rub_1m);
let priceImage: string | null = null;
let imageUnit: ImageUnit | null = null;
let note: string | null = null;
switch (modality) {
case "transcription":
pricePrompt = priceCompletion = null;
note = "тарифицируется по факту распознавания";
break;
case "speech":
pricePrompt = priceCompletion = null;
note = "тарифицируется по факту синтеза";
break;
case "image": {
pricePrompt = priceCompletion = null;
const p = money(api.price_image_rub);
// Пустая единица = цена ещё не запрошена бэкендом; голое число без
// единицы публиковать нельзя (за изображение и за токен — разные порядки).
if (isPositive(p) && isImageUnit(api.price_image_unit)) {
priceImage = p;
imageUnit = api.price_image_unit;
note = `${p} ₽ за ${IMAGE_UNIT_RU[imageUnit]}`;
} else {
note = "цена уточняется";
}
break;
}
case "decision":
// Выход decision-модели бесплатен по конструкции маршрута /v1/systemone.
priceCompletion = null;
break;
default:
break;
}
const id = api.id;
return {
id,
name: cleanText(typeof api.name === "string" ? api.name : id, 200),
description: cleanText(typeof api.description === "string" ? api.description : "", 500),
vendor: id.split("/")[0] ?? id,
modality,
input_modalities: Array.isArray(api.input_modalities)
? api.input_modalities.filter((m): m is string => typeof m === "string")
: [],
context_length: contextLength(api, v1),
is_free: api.is_free === true,
price_prompt_rub_1m: pricePrompt,
price_completion_rub_1m: priceCompletion,
price_image_rub: priceImage,
price_image_unit: imageUnit,
price_note: note,
usd: usdOf(v1),
allowed_for_this_key: v1 !== undefined,
};
}
/** Чистый джойн двух листингов — без сети и кэша, удобен для тестов на фикстурах. */
export function joinCatalog(
apiModels: readonly ApiModel[],
v1Models: readonly V1Model[],
log: (msg: string) => void = (msg) => console.error(msg),
): CatalogItem[] {
const v1ById = new Map(v1Models.map((m) => [m.id, m]));
const apiIds = new Set(apiModels.map((m) => m.id));
const items: CatalogItem[] = [];
let unknownModality = 0;
for (const api of apiModels) {
if (!isModality(api.modality)) {
unknownModality += 1;
continue;
}
items.push(buildItem(api, v1ById.get(api.id), api.modality));
}
// Есть в /v1/models, но нет в /api/models: без рублёвой цены и модальности
// полную запись не построить. Оба листинга читают один каталог, так что это
// гонка синка или ошибка бэкенда — сообщаем одной строкой.
const v1Only = v1Models.filter((m) => !apiIds.has(m.id)).length;
if (v1Only > 0) {
log(`openrusrouter-mcp: ${v1Only} моделей разошлись между /api/models и /v1/models и исключены из каталога`);
}
if (unknownModality > 0) {
log(`openrusrouter-mcp: ${unknownModality} моделей с неизвестной модальностью исключены из каталога`);
}
return items;
}
export async function buildCatalog(client: Client): Promise<CatalogItem[]> {
const [api, v1] = await Promise.all([client.getApiModels(), client.getV1Models()]);
return joinCatalog(api.data, v1.data);
}
+366
View File
@@ -0,0 +1,366 @@
// Узкий HTTP-клиент к API площадки — техническая граница read-only.
//
// Наружу выставлен замороженный объект с фиксированным набором методов
// чтения; универсального «сходить по произвольному пути» нет. Метод запроса
// зашит в единственную приватную функцию getJSON, тела у запросов нет вовсе:
// ключ, которым пользуется MCP, не должен уметь ничего менять в аккаунте даже
// при ошибке в коде инструмента или инъекции в промпт модели.
import type { Config } from "./config.js";
import { ApiError } from "./errors.js";
import { redactString } from "./redact.js";
import { VERSION } from "./version.js";
const ALLOWED = [
"/v1/models",
"/api/models",
"/api/keys/models",
"/api/balance",
"/api/usage",
"/api/keys",
"/v1/auth/key",
] as const;
type AllowedPath = (typeof ALLOWED)[number];
/** Пути, в которые клиент вообще умеет ходить. Экспортируется только ради теста. */
export const ALLOWED_PATHS: readonly string[] = Object.freeze([...ALLOWED]);
const TIMEOUT_MS = 15_000;
const MAX_RESPONSE_BYTES = 5_000_000;
const MAX_ERROR_MESSAGE = 300;
const USER_AGENT = `openrusrouter-mcp/${VERSION}`;
// ---- Типы ответов (по живым срезам API, а не по OpenAPI-спеке) ----
export interface V1Pricing {
prompt?: string;
completion?: string;
request?: string;
image?: string;
input_cache_read?: string;
}
export interface V1Model {
id: string;
object?: string;
created?: number;
owned_by?: string;
name?: string;
description?: string;
context_length?: number;
architecture?: { input_modalities?: string[]; output_modalities?: string[]; modality?: string };
pricing?: V1Pricing;
top_provider?: { context_length?: number; is_moderated?: boolean };
}
export interface V1ModelsResponse {
object?: string;
data: V1Model[];
}
export interface ApiModel {
id: string;
name?: string;
description?: string;
context_length?: number;
modality?: string;
input_modalities?: string[];
is_free?: boolean;
price_prompt_rub_1m?: string;
price_completion_rub_1m?: string;
price_image_rub?: string;
price_image_unit?: string;
}
export interface ApiModelsResponse {
data: ApiModel[];
}
export interface KeyModelsResponse {
models: { id: string; name: string }[];
}
export interface BalanceResponse {
balance: string;
referral_balance: string;
}
export type UsageGroupBy = "day" | "model" | "key" | "key_model";
export interface UsageRow {
model?: string;
day?: string;
key_id?: string;
key_name?: string;
requests: number;
tokens_prompt: number;
tokens_cached: number;
tokens_completion: number;
amount_rub: string;
[extra: string]: unknown;
}
export interface UsageResponse {
data: UsageRow[];
failed_events: number;
group_by: string;
from: string;
to: string;
}
export interface ApiKeyEntry {
id: string;
name: string;
prefix: string;
last_used_at: string | null;
created_at: string;
revoked_at: string | null;
spend_window: string;
spend_limit_rub: string;
spend_used: string;
spend_resets_at?: string;
allowed_models: string[] | null;
}
export interface AuthKeyInfo {
label: string;
balance: string;
account_balance: string;
limit: string | null;
limit_remaining: string | null;
usage: string;
limit_window: string;
limit_resets_at?: string;
}
export interface Client {
getV1Models(): Promise<V1ModelsResponse>;
getApiModels(): Promise<ApiModelsResponse>;
getKeyModels(): Promise<KeyModelsResponse>;
getBalance(): Promise<BalanceResponse>;
getUsage(params: { from: string; to: string; groupBy: UsageGroupBy }): Promise<UsageResponse>;
getKeys(): Promise<{ keys: ApiKeyEntry[] }>;
getAuthKey(): Promise<{ data: AuthKeyInfo }>;
}
// ---- Проверки формы ----
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
class ResponseShapeError extends Error {
override name = "ResponseShapeError";
}
function shapeError(path: string, what: string): never {
throw new ResponseShapeError(`неожиданная форма ответа ${path}: ${what}`);
}
function requireRecord(v: unknown, path: string, what: string): Record<string, unknown> {
if (!isRecord(v)) shapeError(path, `${what} не объект`);
return v;
}
function requireArray(v: unknown, path: string, what: string): unknown[] {
if (!Array.isArray(v)) shapeError(path, `${what} не массив`);
return v;
}
/**
* Денежное поле обязано прийти строкой. Число — fail-closed: double уже
* потерял разряды, и String(number) выдал бы за точную сумму приближение.
*/
function checkMoney(obj: Record<string, unknown>, field: string, path: string, nullable = false): void {
const v = obj[field];
if (typeof v === "number") {
throw new ResponseShapeError(`неожиданный числовой тип у денежного поля ${path}.${field}`);
}
if (typeof v === "string") return;
if (nullable && (v === null || v === undefined)) return;
shapeError(path, `денежное поле ${field} отсутствует или не строка`);
}
function checkOptionalMoney(obj: Record<string, unknown>, field: string, path: string): void {
if (obj[field] === undefined || obj[field] === null) return;
checkMoney(obj, field, path);
}
function checkItemsHaveId(items: unknown[], path: string): void {
for (const it of items) {
const r = requireRecord(it, path, "элемент списка");
if (typeof r.id !== "string" || r.id === "") shapeError(path, "элемент без id");
}
}
function validateBalance(body: unknown, path: string): BalanceResponse {
const r = requireRecord(body, path, "тело");
checkMoney(r, "balance", path);
checkMoney(r, "referral_balance", path);
return r as unknown as BalanceResponse;
}
function validateApiModels(body: unknown, path: string): ApiModelsResponse {
const r = requireRecord(body, path, "тело");
const data = requireArray(r.data, path, "data");
checkItemsHaveId(data, path);
for (const it of data as Record<string, unknown>[]) {
for (const f of ["price_prompt_rub_1m", "price_completion_rub_1m", "price_image_rub"]) {
checkOptionalMoney(it, f, `${path}.data[]`);
}
}
return r as unknown as ApiModelsResponse;
}
function validateV1Models(body: unknown, path: string): V1ModelsResponse {
const r = requireRecord(body, path, "тело");
const data = requireArray(r.data, path, "data");
checkItemsHaveId(data, path);
for (const it of data as Record<string, unknown>[]) {
if (it.pricing === undefined || it.pricing === null) continue;
const p = requireRecord(it.pricing, path, "pricing");
for (const f of ["prompt", "completion", "request", "image", "input_cache_read"]) {
checkOptionalMoney(p, f, `${path}.data[].pricing`);
}
}
return r as unknown as V1ModelsResponse;
}
function validateKeyModels(body: unknown, path: string): KeyModelsResponse {
const r = requireRecord(body, path, "тело");
const models = requireArray(r.models, path, "models");
checkItemsHaveId(models, path);
return r as unknown as KeyModelsResponse;
}
function validateUsage(body: unknown, path: string): UsageResponse {
const r = requireRecord(body, path, "тело");
const data = requireArray(r.data, path, "data");
for (const it of data) {
const row = requireRecord(it, path, "строка");
checkMoney(row, "amount_rub", `${path}.data[]`);
}
if (typeof r.failed_events !== "number") shapeError(path, "failed_events не число");
return r as unknown as UsageResponse;
}
function validateKeys(body: unknown, path: string): { keys: ApiKeyEntry[] } {
const r = requireRecord(body, path, "тело");
const keys = requireArray(r.keys, path, "keys");
for (const it of keys) {
const k = requireRecord(it, path, "ключ");
if (typeof k.id !== "string" || typeof k.prefix !== "string") shapeError(path, "ключ без id/prefix");
checkMoney(k, "spend_limit_rub", `${path}.keys[]`);
checkMoney(k, "spend_used", `${path}.keys[]`);
}
return r as unknown as { keys: ApiKeyEntry[] };
}
function validateAuthKey(body: unknown, path: string): { data: AuthKeyInfo } {
const r = requireRecord(body, path, "тело");
const d = requireRecord(r.data, path, "data");
const p = `${path}.data`;
checkMoney(d, "balance", p);
checkMoney(d, "account_balance", p);
checkMoney(d, "usage", p);
checkMoney(d, "limit", p, true);
checkMoney(d, "limit_remaining", p, true);
return r as unknown as { data: AuthKeyInfo };
}
// ---- Транспорт ----
function parseErrorBody(text: string): { code?: string; message?: string } {
try {
const body: unknown = JSON.parse(text);
if (isRecord(body) && isRecord(body.error)) {
const out: { code?: string; message?: string } = {};
if (typeof body.error.code === "string") out.code = body.error.code;
if (typeof body.error.message === "string") out.message = body.error.message;
return out;
}
} catch {
// тело ошибки не JSON — обойдёмся статусом
}
return {};
}
function truncate(s: string, max: number): string {
const chars = Array.from(s);
return chars.length <= max ? s : `${chars.slice(0, max - 1).join("")}…`;
}
export function createClient({ apiKey, baseUrl }: Config, fetchImpl: typeof fetch = fetch): Client {
const headers = Object.freeze({
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
"User-Agent": USER_AGENT,
});
async function getJSON(path: AllowedPath, query?: URLSearchParams): Promise<unknown> {
const q = query?.toString() ?? "";
const qs = q !== "" ? `?${q}` : "";
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}${qs}`, {
method: "GET",
headers,
// Редирект на другой хост увёз бы туда Authorization.
redirect: "error",
signal: AbortSignal.timeout(TIMEOUT_MS),
});
} catch (err) {
const name = err instanceof Error ? err.name : "Error";
const message = err instanceof Error ? err.message : String(err);
// Только имя и сообщение: cause и прочие поля несут запрос с заголовками.
// eslint-disable-next-line preserve-caught-error -- cause намеренно не прикладывается (утечка Authorization)
throw new Error(redactString(`запрос ${path} не выполнен: ${name}: ${message}`));
}
const length = response.headers.get("content-length");
if (length !== null && Number(length) > MAX_RESPONSE_BYTES) {
throw new Error(`ответ ${path} слишком велик (${length} байт)`);
}
// TODO: без Content-Length размер не ограничен — при необходимости читать поток с подсчётом байт.
const text = await response.text();
if (!response.ok) {
const { code, message } = parseErrorBody(text);
const safe = message !== undefined ? truncate(redactString(message), MAX_ERROR_MESSAGE) : undefined;
throw new ApiError(response.status, path, safe, code);
}
try {
return JSON.parse(text) as unknown;
} catch {
throw new ResponseShapeError(`ответ ${path} не является JSON`);
}
}
const client: Client = {
async getV1Models() {
return validateV1Models(await getJSON("/v1/models"), "/v1/models");
},
async getApiModels() {
return validateApiModels(await getJSON("/api/models"), "/api/models");
},
async getKeyModels() {
return validateKeyModels(await getJSON("/api/keys/models"), "/api/keys/models");
},
async getBalance() {
return validateBalance(await getJSON("/api/balance"), "/api/balance");
},
async getUsage({ from, to, groupBy }) {
const q = new URLSearchParams({ from, to, group_by: groupBy });
return validateUsage(await getJSON("/api/usage", q), "/api/usage");
},
async getKeys() {
return validateKeys(await getJSON("/api/keys"), "/api/keys");
},
async getAuthKey() {
return validateAuthKey(await getJSON("/v1/auth/key"), "/v1/auth/key");
},
};
return Object.freeze(client);
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
// stdout занят протоколом JSON-RPC (stdio-транспорт MCP): любая посторонняя
// печать ломает сессию клиента. Поэтому log/info уводятся в stderr ДО того,
// как что-либо успеет напечатать. Модули ESM исполняются в порядке импорта,
// поэтому перенаправление вынесено в первый импорт.
import "./stdout-guard.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createTtlCache } from "./cache.js";
import { CATALOG_TTL_MS, buildCatalog } from "./catalog.js";
import { createClient } from "./client.js";
import { loadConfig } from "./config.js";
import { redactError } from "./redact.js";
import { registerAllTools } from "./tools/index.js";
import { VERSION } from "./version.js";
function logFatal(kind: string, err: unknown): void {
const r = redactError(err);
console.error(`openrusrouter-mcp: ${kind}: ${r.name}: ${r.message}`);
}
process.on("uncaughtException", (err) => {
logFatal("необработанное исключение", err);
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
logFatal("необработанный отказ промиса", reason);
});
async function main(): Promise<void> {
const config = loadConfig();
const client = createClient(config);
const catalog = createTtlCache(CATALOG_TTL_MS, () => buildCatalog(client));
const server = new McpServer({ name: "openrusrouter", version: VERSION });
registerAllTools(server, { client, catalog, apiKey: config.apiKey });
await server.connect(new StdioServerTransport());
}
main().catch((err: unknown) => {
logFatal("ошибка запуска", err);
process.exit(1);
});
+6
View File
@@ -0,0 +1,6 @@
// Импортируется первым в index.ts: в stdio-режиме stdout принадлежит
// протоколу MCP, и console.log/info любой зависимости испортили бы поток.
// eslint-disable-next-line no-console -- это и есть перенаправление stdout в stderr
console.log = console.error.bind(console);
// eslint-disable-next-line no-console -- то же для info
console.info = console.error.bind(console);
+90
View File
@@ -0,0 +1,90 @@
// Инструменты баланса и расхода: get_balance, get_usage.
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { Client, UsageResponse, UsageRow } from "../client.js";
import * as money from "../money.js";
import { cleanText } from "../sanitize.js";
import { READ_ONLY_ANNOTATIONS, guarded, okResult } from "./common.js";
const DAY_MS = 24 * 60 * 60 * 1000;
/** ISO без миллисекунд — в той же форме, в какой API отдаёт from/to. */
function isoSeconds(d: Date): string {
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function usageWindow(days: number, now: Date = new Date()): { from: string; to: string } {
return { from: isoSeconds(new Date(now.getTime() - days * DAY_MS)), to: isoSeconds(now) };
}
export const FAILED_NOTE =
"часть потребления не закрыта учётом, сумма неизвестна — это не ноль, а отсутствие данных";
/** Строковые поля строки расхода (модель, имя ключа) приходят извне — чистим. */
function cleanRow(row: UsageRow): UsageRow {
const out: UsageRow = { ...row };
for (const [k, v] of Object.entries(out)) {
if (typeof v === "string" && k !== "amount_rub") out[k] = cleanText(v, 200);
}
return out;
}
export function summarizeUsage(resp: UsageResponse, limit: number): Record<string, unknown> {
let total = money.parseDecimal("0");
for (const row of resp.data) total = money.add(total, money.parseDecimal(row.amount_rub));
return {
from: resp.from,
to: resp.to,
group_by: resp.group_by,
items: resp.data.slice(0, limit).map(cleanRow),
truncated: resp.data.length > limit,
total_rub: money.toString(total),
failed_events: resp.failed_events,
failed_note: resp.failed_events > 0 ? FAILED_NOTE : null,
};
}
export function registerBillingTools(server: McpServer, client: Client): void {
server.registerTool(
"get_balance",
{
title: "Баланс аккаунта",
description:
"Текущий баланс аккаунта openrusrouter.ru в рублях и реферальный баланс. Суммы — decimal-строки. Только чтение.",
inputSchema: {},
annotations: READ_ONLY_ANNOTATIONS,
},
() =>
guarded(async () => {
const b = await client.getBalance();
return okResult({
balance_rub: b.balance,
referral_balance_rub: b.referral_balance,
as_of: new Date().toISOString(),
});
}),
);
server.registerTool(
"get_usage",
{
title: "Расход за период",
description:
"Расход аккаунта за последние N дней с группировкой по дню, модели, ключу или ключу и модели: запросы, токены и сумма в ₽. " +
"total_rub считается по всем строкам до обрезки limit. Если failed_events > 0, часть потребления ещё не учтена — сумма по ней неизвестна, а не равна нулю.",
inputSchema: {
days: z.number().int().min(1).max(180).default(7),
group_by: z.enum(["day", "model", "key", "key_model"]).default("model"),
limit: z.number().int().min(1).max(50).default(20),
},
annotations: READ_ONLY_ANNOTATIONS,
},
(args) =>
guarded(async () => {
const { from, to } = usageWindow(args.days);
const resp = await client.getUsage({ from, to, groupBy: args.group_by });
return okResult(summarizeUsage(resp, args.limit));
}),
);
}
+32
View File
@@ -0,0 +1,32 @@
// Общие для инструментов обёртки результата и аннотации.
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { toToolErrorResult } from "../errors.js";
/** Все инструменты пакета только читают: ни один не меняет состояние аккаунта. */
export const READ_ONLY_ANNOTATIONS = Object.freeze({
readOnlyHint: true,
idempotentHint: true,
openWorldHint: true,
destructiveHint: false,
});
export function okResult(data: Record<string, unknown>): CallToolResult {
return {
content: [{ type: "text", text: JSON.stringify(data) }],
structuredContent: data,
};
}
export function errorResult(text: string): CallToolResult {
return { isError: true, content: [{ type: "text", text }] };
}
/** Любое исключение обработчика превращается в isError-результат с вычищенным текстом. */
export async function guarded(fn: () => Promise<CallToolResult>): Promise<CallToolResult> {
try {
return await fn();
} catch (err) {
return toToolErrorResult(err);
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CatalogSource } from "../catalog.js";
import type { Client } from "../client.js";
import { registerBillingTools } from "./billing.js";
import { registerKeyTools } from "./keys.js";
import { registerModelTools } from "./models.js";
export interface ToolDeps {
client: Client;
/** Общий на процесс TTL-кэш каталога. */
catalog: CatalogSource;
/** Нужен только для локальной отметки is_current_key; в результаты не попадает. */
apiKey: string;
}
export function registerAllTools(server: McpServer, deps: ToolDeps): void {
registerModelTools(server, deps.catalog);
registerBillingTools(server, deps.client);
registerKeyTools(server, deps.client, deps.apiKey);
}
+61
View File
@@ -0,0 +1,61 @@
// Инструмент list_api_keys — только чтение списка ключей аккаунта.
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { ApiKeyEntry, Client } from "../client.js";
import { cleanText } from "../sanitize.js";
import { READ_ONLY_ANNOTATIONS, guarded, okResult } from "./common.js";
export const KEYS_TOOL_DESCRIPTION =
"Список API-ключей аккаунта openrusrouter.ru: префикс, лимит расхода и его окно, израсходовано, разрешённые модели. " +
"Полный ключ не выводится никогда, только префикс; is_current_key отмечает ключ, которым работает этот MCP. " +
"allowed_models: null вместе с all_models_allowed: true означает, что ключу доступны все модели. " +
"Создать, отозвать или изменить ключ через MCP нельзя — такие действия требуют браузерной сессии на openrusrouter.ru. " +
"Это сознательное ограничение безопасности: ключ, которым пользуется MCP, не должен уметь управлять другими ключами или собой";
export function presentKeys(
keys: readonly ApiKeyEntry[],
currentApiKey: string,
includeRevoked: boolean,
): Record<string, unknown>[] {
return keys
.filter((k) => includeRevoked || k.revoked_at === null)
.map((k) => {
const allowed = Array.isArray(k.allowed_models) && k.allowed_models.length > 0 ? k.allowed_models : null;
return {
id: k.id,
name: cleanText(typeof k.name === "string" ? k.name : "", 200),
key_prefix: k.prefix,
created_at: k.created_at,
last_used_at: k.last_used_at ?? null,
revoked_at: k.revoked_at ?? null,
spend_window: k.spend_window === "" ? null : k.spend_window,
spend_limit_rub: k.spend_limit_rub,
spend_used_rub: k.spend_used,
spend_resets_at: k.spend_resets_at ?? null,
allowed_models: allowed === null ? null : allowed.map((m) => cleanText(String(m), 200)),
all_models_allowed: allowed === null,
// Сравнение локальное: сам ключ в результат не попадает.
is_current_key: k.prefix !== "" && currentApiKey.startsWith(k.prefix),
};
});
}
export function registerKeyTools(server: McpServer, client: Client, currentApiKey: string): void {
server.registerTool(
"list_api_keys",
{
title: "API-ключи аккаунта",
description: KEYS_TOOL_DESCRIPTION,
inputSchema: {
include_revoked: z.boolean().default(false),
},
annotations: READ_ONLY_ANNOTATIONS,
},
(args) =>
guarded(async () => {
const { keys } = await client.getKeys();
return okResult({ keys: presentKeys(keys, currentApiKey, args.include_revoked) });
}),
);
}
+360
View File
@@ -0,0 +1,360 @@
// Инструменты каталога: list_models, pick_model, estimate_cost.
// Все сравнения цен — decimal-строками через money, не через parseFloat.
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { MODALITIES, type CatalogItem, type CatalogSource, type Modality } from "../catalog.js";
import * as money from "../money.js";
import { READ_ONLY_ANNOTATIONS, errorResult, guarded, okResult } from "./common.js";
// ---- Чистые функции ----
export interface ModelFilter {
modality?: Modality | undefined;
vendor?: string | undefined;
query?: string | undefined;
min_context?: number | undefined;
vision?: boolean | undefined;
free_only?: boolean | undefined;
include_disallowed?: boolean | undefined;
max_prompt_rub_1m?: string | undefined;
max_completion_rub_1m?: string | undefined;
}
/** Цена выхода для сравнения: у decision выход бесплатен по конструкции. */
function effectiveCompletion(item: CatalogItem): string | null {
return item.modality === "decision" ? "0" : item.price_completion_rub_1m;
}
function withinMax(price: string | null, max: string | undefined): boolean {
if (max === undefined) return true;
// Неизвестная цена не проходит ограничение сверху: «не знаем» не равно «дёшево».
if (price === null) return false;
return money.compare(money.parseDecimal(price), money.parseDecimal(max)) <= 0;
}
export function filterModels(items: readonly CatalogItem[], f: ModelFilter): CatalogItem[] {
const query = f.query?.trim().toLowerCase();
const vendor = f.vendor?.trim().toLowerCase();
return items.filter((it) => {
if (f.modality !== undefined && it.modality !== f.modality) return false;
if (vendor !== undefined && vendor !== "" && it.vendor.toLowerCase() !== vendor) return false;
if (query !== undefined && query !== "") {
if (!it.id.toLowerCase().includes(query) && !it.name.toLowerCase().includes(query)) return false;
}
if (f.min_context !== undefined && f.min_context > 0) {
if (it.context_length === null || it.context_length < f.min_context) return false;
}
if (f.vision === true && !it.input_modalities.includes("image")) return false;
if (f.free_only === true && !it.is_free) return false;
if (f.include_disallowed !== true && !it.allowed_for_this_key) return false;
if (!withinMax(it.price_prompt_rub_1m, f.max_prompt_rub_1m)) return false;
if (!withinMax(effectiveCompletion(it), f.max_completion_rub_1m)) return false;
return true;
});
}
/** Сравнение decimal-строк, null — всегда в конец (это «цена неизвестна», а не ноль). */
function compareNullableMoney(a: string | null, b: string | null): number {
if (a === null && b === null) return 0;
if (a === null) return 1;
if (b === null) return -1;
return money.compare(money.parseDecimal(a), money.parseDecimal(b));
}
function compareContextDesc(a: number | null, b: number | null): number {
if (a === null && b === null) return 0;
if (a === null) return 1;
if (b === null) return -1;
return b - a;
}
function byId(a: CatalogItem, b: CatalogItem): number {
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
export type ListSort = "price" | "context" | "name";
export function sortModels(items: readonly CatalogItem[], sort: ListSort): CatalogItem[] {
const out = [...items];
out.sort((a, b) => {
let c: number;
if (sort === "price") {
c = compareNullableMoney(a.price_prompt_rub_1m, b.price_prompt_rub_1m);
if (c === 0) c = compareNullableMoney(effectiveCompletion(a), effectiveCompletion(b));
} else if (sort === "context") {
c = compareContextDesc(a.context_length, b.context_length);
} else {
c = a.name.localeCompare(b.name, "en");
}
return c !== 0 ? c : byId(a, b);
});
return out;
}
export interface TokenCounts {
prompt_tokens: number;
completion_tokens?: number | undefined;
cached_tokens?: number | undefined;
}
export type EstimateResult =
| { ok: true; cost_rub: string; cost_rub_rounded: string; cost_usd: string | null; basis: string }
| { ok: false; error: string };
const NO_TOKEN_PRICE = new Set<Modality>(["image", "transcription", "speech"]);
function tokensCost(tokens: number, pricePer1M: string): money.Decimal {
return money.divPow10(money.mulByBigInt(money.parseDecimal(pricePer1M), BigInt(tokens)), 6);
}
function tokensCostPerToken(tokens: number, pricePerToken: string): money.Decimal {
return money.mulByBigInt(money.parseDecimal(pricePerToken), BigInt(tokens));
}
/**
* Оценка стоимости запроса. cost_rub — по рублёвым ценам аккаунта БЕЗ скидки
* за кэш (рублёвой цены кэш-чтения API не публикует); cost_usd — по USD-ценам
* /v1/models, со скидкой за кэш, если модель её объявляет.
*/
export function estimateCost(item: CatalogItem, t: TokenCounts): EstimateResult {
if (NO_TOKEN_PRICE.has(item.modality)) {
return {
ok: false,
error: "у этой модальности нет цены за токен — тарификация по факту, оценка невозможна",
};
}
const prompt = t.prompt_tokens;
const completion = t.completion_tokens ?? 0;
const cached = t.cached_tokens;
for (const n of [prompt, completion, cached ?? 0]) {
if (!Number.isSafeInteger(n) || n < 0) return { ok: false, error: "число токенов должно быть целым ≥ 0" };
}
if (cached !== undefined && cached > prompt) {
return { ok: false, error: "cached_tokens не может превышать prompt_tokens: кэш — часть промпта" };
}
const pricePrompt = item.price_prompt_rub_1m;
const priceCompletion = effectiveCompletion(item);
if (pricePrompt === null || (priceCompletion === null && completion > 0)) {
return { ok: false, error: `у модели ${item.id} не опубликована цена за токен — оценка невозможна` };
}
let rub = tokensCost(prompt, pricePrompt);
if (completion > 0 && priceCompletion !== null) rub = money.add(rub, tokensCost(completion, priceCompletion));
const basis: string[] = ["cost_rub — по ценам этого аккаунта (₽ за 1M токенов с персональной наценкой), без учёта скидки за кэш"];
if (item.modality === "decision") basis.push("выход decision-модели не тарифицируется");
let usd: string | null = null;
if (item.usd !== null) {
const cachedN = cached ?? 0;
const cacheRead = item.usd.input_cache_read;
let u = tokensCostPerToken(prompt - cachedN, item.usd.prompt);
if (cachedN > 0 && cacheRead !== null) {
u = money.add(u, tokensCostPerToken(cachedN, cacheRead));
basis.push("cost_usd — по USD-ценам каталога, кэшированные токены по цене кэш-чтения");
} else {
if (cachedN > 0) u = money.add(u, tokensCostPerToken(cachedN, item.usd.prompt));
basis.push(
cachedN > 0
? "cost_usd — по USD-ценам каталога; модель не объявляет цену кэш-чтения, кэш посчитан по полной цене"
: "cost_usd — по USD-ценам каталога",
);
}
if (completion > 0) u = money.add(u, tokensCostPerToken(completion, item.usd.completion));
usd = money.toString(u);
} else {
basis.push("USD-цены для модели недоступны (нет в /v1/models для этого ключа или курс не опубликован)");
}
basis.push("это оценка: фактическое списание идёт по usage ответа провайдера");
return {
ok: true,
cost_rub: money.toString(rub),
cost_rub_rounded: money.toFixed(rub, 2),
cost_usd: usd,
basis: basis.join("; "),
};
}
export type Prefer = "cheapest" | "largest_context";
export interface RankOptions {
prefer: Prefer;
expected_prompt_tokens?: number | undefined;
expected_completion_tokens?: number | undefined;
}
export type RankedItem = CatalogItem & { est_cost_rub: string | null; why: string };
export const WHY = Object.freeze({
cheapestEstimate: "наименьшая оценочная стоимость при заданном объёме токенов среди подходящих",
nextEstimate: "следующая по оценочной стоимости при заданном объёме токенов",
cheapestPrompt: "самая дешёвая по входным токенам среди подходящих",
nextPrompt: "следующая по цене входных токенов",
largestContext: "наибольший контекст среди подходящих",
nextContext: "следующая по размеру контекста",
noPrice: "цена за токен не опубликована — поставлена в конец",
noContext: "размер контекста не опубликован — поставлена в конец",
});
export function rankModels(items: readonly CatalogItem[], opts: RankOptions): RankedItem[] {
const withEstimate = opts.expected_prompt_tokens !== undefined || opts.expected_completion_tokens !== undefined;
const enriched = items.map((it) => {
let est: string | null = null;
if (withEstimate) {
const r = estimateCost(it, {
prompt_tokens: opts.expected_prompt_tokens ?? 0,
completion_tokens: opts.expected_completion_tokens ?? 0,
});
if (r.ok) est = r.cost_rub;
}
return { it, est };
});
enriched.sort((a, b) => {
let c: number;
if (opts.prefer === "largest_context") {
c = compareContextDesc(a.it.context_length, b.it.context_length);
} else if (withEstimate) {
c = compareNullableMoney(a.est, b.est);
} else {
c = compareNullableMoney(a.it.price_prompt_rub_1m, b.it.price_prompt_rub_1m);
}
return c !== 0 ? c : byId(a.it, b.it);
});
return enriched.map(({ it, est }, i) => {
let why: string;
if (opts.prefer === "largest_context") {
why = it.context_length === null ? WHY.noContext : i === 0 ? WHY.largestContext : WHY.nextContext;
} else if (withEstimate) {
why = est === null ? WHY.noPrice : i === 0 ? WHY.cheapestEstimate : WHY.nextEstimate;
} else {
why = it.price_prompt_rub_1m === null ? WHY.noPrice : i === 0 ? WHY.cheapestPrompt : WHY.nextPrompt;
}
return {
...it,
est_cost_rub: est === null ? null : money.toFixed(money.parseDecimal(est), 2),
why,
};
});
}
// ---- Регистрация ----
const decimalString = z
.string()
.regex(/^\d+(\.\d+)?$/, "ожидается неотрицательное десятичное число строкой, например \"12.5\"");
export function registerModelTools(server: McpServer, catalog: CatalogSource): void {
server.registerTool(
"list_models",
{
title: "Каталог моделей",
description:
"Список моделей openrusrouter.ru с фильтрами (модальность, вендор, подстрока, контекст, vision, бесплатные) и сортировкой. " +
"Цены — ₽ за 1M токенов с персональной наценкой этого аккаунта, это не публичная цена. " +
"У image/transcription/speech токенной цены нет — см. price_note. " +
"По умолчанию показаны только модели, доступные текущему ключу; include_disallowed покажет и модели, недоступные текущему ключу.",
inputSchema: {
modality: z.enum(MODALITIES).default("text"),
vendor: z.string().max(100).optional(),
query: z.string().max(200).optional(),
min_context: z.number().int().min(0).optional(),
vision: z.boolean().optional(),
free_only: z.boolean().default(false),
include_disallowed: z.boolean().default(false),
sort: z.enum(["price", "context", "name"]).default("price"),
limit: z.number().int().min(1).max(50).default(20),
offset: z.number().int().min(0).default(0),
},
annotations: READ_ONLY_ANNOTATIONS,
},
(args) =>
guarded(async () => {
const filtered = filterModels(await catalog(), args);
const sorted = sortModels(filtered, args.sort);
return okResult({
total: sorted.length,
offset: args.offset,
items: sorted.slice(args.offset, args.offset + args.limit),
});
}),
);
server.registerTool(
"pick_model",
{
title: "Подбор модели",
description:
"Подбирает модели под требования (модальность, vision, контекст, потолок цены, ожидаемый объём токенов). " +
"Это локальный фильтр по уже известным ценам каталога, не рекомендация от ИИ; " +
"подбор идёт только среди моделей, доступных текущему ключу (among allowed for this key only). " +
"Цены — ₽ за 1M токенов с персональной наценкой аккаунта; est_cost_rub — оценка без скидки за кэш.",
inputSchema: {
modality: z.enum(MODALITIES).default("text"),
need_vision: z.boolean().optional(),
min_context: z.number().int().min(0).optional(),
max_prompt_rub_1m: decimalString.optional(),
max_completion_rub_1m: decimalString.optional(),
expected_prompt_tokens: z.number().int().min(0).optional(),
expected_completion_tokens: z.number().int().min(0).optional(),
prefer: z.enum(["cheapest", "largest_context"]).default("cheapest"),
limit: z.number().int().min(1).max(10).default(5),
},
annotations: READ_ONLY_ANNOTATIONS,
},
(args) =>
guarded(async () => {
const filtered = filterModels(await catalog(), {
modality: args.modality,
vision: args.need_vision,
min_context: args.min_context,
max_prompt_rub_1m: args.max_prompt_rub_1m,
max_completion_rub_1m: args.max_completion_rub_1m,
include_disallowed: false,
});
const ranked = rankModels(filtered, {
prefer: args.prefer,
expected_prompt_tokens: args.expected_prompt_tokens,
expected_completion_tokens: args.expected_completion_tokens,
});
return okResult({ total: ranked.length, items: ranked.slice(0, args.limit) });
}),
);
server.registerTool(
"estimate_cost",
{
title: "Оценка стоимости запроса",
description:
"Оценивает стоимость запроса к модели по числу токенов. cost_rub — по рублёвым ценам этого аккаунта без учёта скидки за кэш, " +
"cost_usd — по USD-ценам каталога с учётом цены кэш-чтения, если модель её объявляет. " +
"Для image/transcription/speech оценка невозможна: они тарифицируются по факту.",
inputSchema: {
model_id: z.string().min(1).max(200),
prompt_tokens: z.number().int().min(0),
completion_tokens: z.number().int().min(0).default(0),
cached_tokens: z.number().int().min(0).optional(),
},
annotations: READ_ONLY_ANNOTATIONS,
},
(args) =>
guarded(async () => {
const item = (await catalog()).find((m) => m.id === args.model_id);
if (item === undefined) {
return errorResult(`модель ${JSON.stringify(args.model_id.slice(0, 200))} не найдена в каталоге`);
}
const r = estimateCost(item, args);
if (!r.ok) return errorResult(r.error);
return okResult({
model_id: item.id,
cost_rub: r.cost_rub,
cost_rub_rounded: r.cost_rub_rounded,
cost_usd: r.cost_usd,
basis: r.basis,
});
}),
);
}
+16
View File
@@ -0,0 +1,16 @@
import { createRequire } from "node:module";
// package.json лежит уровнем выше и src/, и dist/, поэтому путь один для обоих.
function readVersion(): string {
try {
const pkg: unknown = createRequire(import.meta.url)("../package.json");
if (typeof pkg === "object" && pkg !== null && "version" in pkg && typeof pkg.version === "string") {
return pkg.version;
}
} catch {
// версия — витрина (User-Agent, имя сервера), без неё всё работает
}
return "0.0.0";
}
export const VERSION = readVersion();
+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));
});