Добавь 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:
1 parent
ade6eb82df
commit
d7d02267e2
18 files changed
+1935
No files matched your search
+182
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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));
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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) });
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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, не должен уметь управлять другими ключами или собой",
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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: "Evilname", description: "a\u0007bc" }],
|
||||
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);
|
||||
});
|
||||
@@ -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(), /неожиданная форма ответа/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)}`);
|
||||
}
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user