diff --git a/src/catalog.ts b/src/catalog.ts new file mode 100644 index 0000000..7d2a702 --- /dev/null +++ b/src/catalog.ts @@ -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; + +export const CATALOG_TTL_MS = 10 * 60 * 1000; + +const IMAGE_UNIT_RU: Record = { + 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 { + const [api, v1] = await Promise.all([client.getApiModels(), client.getV1Models()]); + return joinCatalog(api.data, v1.data); +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..6e1b3d8 --- /dev/null +++ b/src/client.ts @@ -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; + getApiModels(): Promise; + getKeyModels(): Promise; + getBalance(): Promise; + getUsage(params: { from: string; to: string; groupBy: UsageGroupBy }): Promise; + getKeys(): Promise<{ keys: ApiKeyEntry[] }>; + getAuthKey(): Promise<{ data: AuthKeyInfo }>; +} + +// ---- Проверки формы ---- + +function isRecord(v: unknown): v is Record { + 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 { + 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, 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, 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[]) { + 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[]) { + 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 { + 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); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..1495959 --- /dev/null +++ b/src/index.ts @@ -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 { + 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); +}); diff --git a/src/stdout-guard.ts b/src/stdout-guard.ts new file mode 100644 index 0000000..2f3713a --- /dev/null +++ b/src/stdout-guard.ts @@ -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); diff --git a/src/tools/billing.ts b/src/tools/billing.ts new file mode 100644 index 0000000..b628e94 --- /dev/null +++ b/src/tools/billing.ts @@ -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 { + 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)); + }), + ); +} diff --git a/src/tools/common.ts b/src/tools/common.ts new file mode 100644 index 0000000..a123d47 --- /dev/null +++ b/src/tools/common.ts @@ -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): 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): Promise { + try { + return await fn(); + } catch (err) { + return toToolErrorResult(err); + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..fbbb8eb --- /dev/null +++ b/src/tools/index.ts @@ -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); +} diff --git a/src/tools/keys.ts b/src/tools/keys.ts new file mode 100644 index 0000000..6934983 --- /dev/null +++ b/src/tools/keys.ts @@ -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[] { + 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) }); + }), + ); +} diff --git a/src/tools/models.ts b/src/tools/models.ts new file mode 100644 index 0000000..39c9d00 --- /dev/null +++ b/src/tools/models.ts @@ -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(["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, + }); + }), + ); +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..60e9bfc --- /dev/null +++ b/src/version.ts @@ -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(); diff --git a/test/billing-keys.test.ts b/test/billing-keys.test.ts new file mode 100644 index 0000000..fec63c2 --- /dev/null +++ b/test/billing-keys.test.ts @@ -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("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, не должен уметь управлять другими ключами или собой", + ), + ); +}); diff --git a/test/catalog.test.ts b/test/catalog.test.ts new file mode 100644 index 0000000..4664922 --- /dev/null +++ b/test/catalog.test.ts @@ -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("api-models"); +const v1 = fixture("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 (v: T): Promise => { + 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); +}); diff --git a/test/client.test.ts b/test/client.test.ts new file mode 100644 index 0000000..8f8da79 --- /dev/null +++ b/test/client.test.ts @@ -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 = { + "/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).request = () => undefined; + }, TypeError); + assert.throws(() => { + (client as unknown as Record).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; + 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[] }>("api-keys"); + (body.keys[0] as Record).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(), /неожиданная форма ответа/); +}); diff --git a/test/estimate-cost.test.ts b/test/estimate-cost.test.ts new file mode 100644 index 0000000..141e10b --- /dev/null +++ b/test/estimate-cost.test.ts @@ -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); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..84b6690 --- /dev/null +++ b/test/helpers.ts @@ -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(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("api-models"); + const v1 = fixture("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, + }; +} diff --git a/test/pick-model.test.ts b/test/pick-model.test.ts new file mode 100644 index 0000000..529266c --- /dev/null +++ b/test/pick-model.test.ts @@ -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); +}); diff --git a/test/stdout-clean.test.ts b/test/stdout-clean.test.ts new file mode 100644 index 0000000..5a4f787 --- /dev/null +++ b/test/stdout-clean.test.ts @@ -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) => 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; + 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>((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 }[] }).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)}`); + } +}); diff --git a/test/tools.test.ts b/test/tools.test.ts new file mode 100644 index 0000000..69eab8d --- /dev/null +++ b/test/tools.test.ts @@ -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 = { + "/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 = {}; + 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; content: { text: string }[] }; + +async function call(mcp: McpClient, name: string, args: Record = {}): Promise { + 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)); +});