Добавь HTTP-клиент, каталог моделей и шесть read-only инструментов MCP

Клиент — замороженный объект с семью методами GET, без универсального
запроса и тела; денежные поля проверяются на строковый тип (fail-closed).
Каталог джойнит /api/models и /v1/models: модальность и рубли из первого,
USD и доступность ключу из второго. Инструменты: list_models, pick_model,
estimate_cost, get_balance, get_usage, list_api_keys. Точка входа stdio
уводит console.log/info в stderr первым импортом.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vasyanskandClaude Opus 5.5 committed 2026-10-24 10:59:06 +07:00
1 parent ade6eb82df
commit d7d02267e2
18 files changed
+1935

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import { ApiError } from "../src/errors.js";
import { ALLOWED_PATHS, createClient } from "../src/client.js";
import { fixture } from "./helpers.js";
const BASE = "https://example.test";
const KEY = "orr_live_TESTKEY0000000000000000000000000";
interface Call {
url: URL;
init: RequestInit | undefined;
}
const FIXTURE_BY_PATH: Record<string, string> = {
"/v1/models": "v1-models",
"/api/models": "api-models",
"/api/keys/models": "keys-models",
"/api/balance": "api-balance",
"/api/usage": "api-usage",
"/api/keys": "api-keys",
"/v1/auth/key": "v1-auth-key",
};
function fakeFetch(override?: (url: URL) => Response | undefined): { fetchImpl: typeof fetch; calls: Call[] } {
const calls: Call[] = [];
const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
const url = new URL(String(input));
calls.push({ url, init });
const custom = override?.(url);
if (custom !== undefined) return custom;
const name = FIXTURE_BY_PATH[url.pathname];
if (name === undefined) return new Response("{}", { status: 404 });
return new Response(JSON.stringify(fixture(name)), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
return { fetchImpl, calls };
}
const METHODS = [
"getV1Models",
"getApiModels",
"getKeyModels",
"getBalance",
"getUsage",
"getKeys",
"getAuthKey",
] as const;
test("клиент — замороженный объект ровно с семью методами чтения", () => {
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fakeFetch().fetchImpl);
assert.deepEqual(Object.keys(client).sort(), [...METHODS].sort());
assert.ok(Object.isFrozen(client));
// Модули ESM — strict mode: запись в замороженный объект бросает TypeError.
assert.throws(() => {
(client as unknown as Record<string, unknown>).request = () => undefined;
}, TypeError);
assert.throws(() => {
(client as unknown as Record<string, unknown>).getBalance = () => undefined;
}, TypeError);
assert.equal("request" in client, false);
});
test("модуль экспортирует только createClient и ALLOWED_PATHS", async () => {
const mod = await import("../src/client.js");
assert.deepEqual(Object.keys(mod).sort(), ["ALLOWED_PATHS", "createClient"]);
assert.equal(ALLOWED_PATHS.length, 7);
});
test("каждый метод шлёт GET без тела на путь из ALLOWED_PATHS с нужными заголовками", async () => {
const { fetchImpl, calls } = fakeFetch();
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await client.getV1Models();
await client.getApiModels();
await client.getKeyModels();
await client.getBalance();
await client.getUsage({ from: "2026-10-17T00:00:00Z", to: "2026-10-24T00:00:00Z", groupBy: "model" });
await client.getKeys();
await client.getAuthKey();
assert.equal(calls.length, 7);
for (const c of calls) {
assert.equal(c.init?.method, "GET");
assert.equal(c.init?.body, undefined);
assert.equal(c.init?.redirect, "error");
assert.ok(c.init?.signal instanceof AbortSignal);
assert.ok(ALLOWED_PATHS.includes(c.url.pathname), c.url.pathname);
assert.equal(c.url.origin, BASE);
const h = c.init?.headers as Record<string, string>;
assert.equal(h.Authorization, `Bearer ${KEY}`);
assert.equal(h.Accept, "application/json");
assert.match(h["User-Agent"] ?? "", /^openrusrouter-mcp\/\d+\.\d+\.\d+/);
}
assert.deepEqual(new Set(calls.map((c) => c.url.pathname)), new Set(ALLOWED_PATHS));
const usage = calls.find((c) => c.url.pathname === "/api/usage");
assert.equal(usage?.url.searchParams.get("group_by"), "model");
assert.equal(usage?.url.searchParams.get("from"), "2026-10-17T00:00:00Z");
});
test("исходник client.ts не содержит мутирующих HTTP-методов, fetch( вызывается только в нём", () => {
const srcDir = new URL("../src/", import.meta.url);
const own = readFileSync(new URL("client.ts", srcDir), "utf8");
for (const m of ["POST", "PUT", "PATCH", "DELETE"]) {
assert.ok(!own.includes(`"${m}"`) && !own.includes(`'${m}'`) && !own.includes(`\`${m}\``), m);
}
const files = readdirSync(srcDir, { recursive: true, encoding: "utf8" }).filter(
(f) => f.endsWith(".ts") && !f.startsWith("generated"),
);
assert.ok(files.length > 5);
for (const f of files) {
if (f === "client.ts") continue;
const text = readFileSync(new URL(f, srcDir), "utf8");
assert.ok(!/\bfetch\s*\(/.test(text), `вызов fetch( в src/${f}`);
}
});
test("денежное поле числом — fail-closed ошибка, а не String(number)", async () => {
const { fetchImpl } = fakeFetch((url) =>
url.pathname === "/api/balance"
? new Response(JSON.stringify({ balance: 1234.56, referral_balance: "0" }), { status: 200 })
: undefined,
);
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await assert.rejects(client.getBalance(), /неожиданный числовой тип у денежного поля \/api\/balance\.balance/);
});
test("числовая сумма в ключе и в /v1/auth/key тоже отвергается", async () => {
const { fetchImpl } = fakeFetch((url) => {
if (url.pathname === "/api/keys") {
const body = fixture<{ keys: Record<string, unknown>[] }>("api-keys");
(body.keys[0] as Record<string, unknown>).spend_used = 12.5;
return new Response(JSON.stringify(body), { status: 200 });
}
if (url.pathname === "/v1/auth/key") {
return new Response(JSON.stringify({ data: { ...fixture<{ data: object }>("v1-auth-key").data, usage: 1 } }), {
status: 200,
});
}
return undefined;
});
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await assert.rejects(client.getKeys(), /spend_used/);
await assert.rejects(client.getAuthKey(), /usage/);
});
test("401 с телом ошибки — ApiError со статусом, кодом и путём", async () => {
const { fetchImpl } = fakeFetch(
() =>
new Response(JSON.stringify({ error: { code: "invalid_key", message: `bad key ${KEY} ${"x".repeat(500)}` } }), {
status: 401,
}),
);
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
const err = await client.getBalance().then(
() => assert.fail("ожидалась ошибка"),
(e: unknown) => e,
);
assert.ok(err instanceof ApiError);
assert.equal(err.status, 401);
assert.equal(err.code, "invalid_key");
assert.equal(err.path, "/api/balance");
assert.ok(Array.from(err.message).length <= 300);
assert.ok(!err.message.includes(KEY));
});
test("сетевая ошибка оборачивается без cause и с вычищенным ключом", async () => {
const fetchImpl = (async () => {
const e = new TypeError(`connect failed, Authorization: Bearer ${KEY}`);
(e as unknown as { cause: unknown }).cause = { headers: { Authorization: `Bearer ${KEY}` } };
throw e;
}) as typeof fetch;
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
const err = await client.getBalance().then(
() => assert.fail("ожидалась ошибка"),
(e: unknown) => e as Error,
);
assert.ok(!err.message.includes(KEY));
assert.match(err.message, /TypeError/);
assert.equal(err.cause, undefined);
});
test("ответ с Content-Length больше 5 МБ отвергается до чтения тела", async () => {
const { fetchImpl } = fakeFetch(
() => new Response("{}", { status: 200, headers: { "content-length": "5000001" } }),
);
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await assert.rejects(client.getBalance(), /слишком велик/);
});
test("/api/keys без обёртки keys — ошибка формы", async () => {
const { fetchImpl } = fakeFetch((url) =>
url.pathname === "/api/keys" ? new Response(JSON.stringify([]), { status: 200 }) : undefined,
);
const client = createClient({ apiKey: KEY, baseUrl: BASE }, fetchImpl);
await assert.rejects(client.getKeys(), /неожиданная форма ответа/);
});