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

+105
View File
@@ -0,0 +1,105 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildCatalog, joinCatalog } from "../src/catalog.js";
import type { ApiModelsResponse, Client, V1ModelsResponse } from "../src/client.js";
import { byId, fixture, fixtureCatalog } from "./helpers.js";
const api = fixture<ApiModelsResponse>("api-models");
const v1 = fixture<V1ModelsResponse>("v1-models");
test("transcription и speech: пометка вместо цены, токенные цены null", () => {
const items = fixtureCatalog();
for (const [id, note] of [
["openai/whisper-1", "тарифицируется по факту распознавания"],
["assemblyai/universal-3-5-pro", "тарифицируется по факту распознавания"],
["elevenlabs/eleven-flash-v2", "тарифицируется по факту синтеза"],
] as const) {
const m = byId(items, id);
assert.equal(m.price_note, note, id);
assert.equal(m.price_prompt_rub_1m, null, id);
assert.equal(m.price_completion_rub_1m, null, id);
assert.equal(m.context_length, null, id);
}
});
test("image: цена с единицей на русском; без единицы — «цена уточняется»", () => {
const items = fixtureCatalog();
const seedream = byId(items, "bytedance-seed/seedream-4.5");
assert.equal(seedream.price_note, "4.56651027 ₽ за изображение");
assert.equal(seedream.price_image_unit, "image");
assert.equal(seedream.price_prompt_rub_1m, null);
const gemini = byId(items, "google/gemini-2.5-flash-image");
// единица token у бэкенда — цена за 1M токенов, не за один
assert.equal(gemini.price_note, "3424.8827025 ₽ за 1M токенов");
const flux = byId(items, "black-forest-labs/flux-3-image");
assert.equal(flux.price_note, "цена уточняется");
assert.equal(flux.price_image_rub, null);
assert.equal(flux.price_image_unit, null);
});
test("decision: модальность из /api/models, completion-цена null", () => {
const items = fixtureCatalog();
const jev = byId(items, "typesafe/jev-1.13.0");
assert.equal(jev.modality, "decision"); // /v1/models называет её text->text
assert.equal(jev.price_completion_rub_1m, null);
assert.equal(jev.price_prompt_rub_1m, "4.79483578");
assert.equal(jev.price_note, null);
});
test("text: рублёвые и USD-цены, vendor, allowed_for_this_key", () => {
const items = fixtureCatalog();
assert.equal(items.length, api.data.length);
assert.ok(items.every((m) => m.allowed_for_this_key));
const ds = byId(items, "deepseek/deepseek-v4-flash");
assert.equal(ds.vendor, "deepseek");
assert.equal(ds.price_prompt_rub_1m, "10.38881086");
assert.deepEqual(ds.usd, {
prompt: "0.0000001289925",
completion: "0.000000257985",
input_cache_read: "0.0000000257985",
});
assert.equal(byId(items, "qwen/qwen3-coder-30b-a3b-instruct").usd?.input_cache_read, null);
assert.equal(byId(items, "openai/whisper-1").usd, null);
assert.equal(ds.price_note, null);
});
test("модель только в /api/models — недоступна ключу; только в /v1/models — исключена с одной строкой в лог", () => {
const logs: string[] = [];
const v1Restricted = v1.data.filter((m) => m.id !== "anthropic/claude-sonnet-5.5");
const extraV1 = [...v1Restricted, { id: "ghost/model-a" }, { id: "ghost/model-b" }];
const items = joinCatalog(api.data, extraV1, (m) => logs.push(m));
assert.equal(byId(items, "anthropic/claude-sonnet-5.5").allowed_for_this_key, false);
assert.equal(byId(items, "anthropic/claude-sonnet-5.5").usd, null);
assert.equal(items.find((m) => m.id.startsWith("ghost/")), undefined);
assert.equal(logs.length, 1);
assert.match(logs[0] ?? "", /2 моделей разошлись/);
});
test("описания чистятся от управляющих и bidi-символов", () => {
const items = joinCatalog(
[{ ...(api.data[0] as ApiModelsResponse["data"][number]), name: "Evil‮name", description: "a\u0007b​c" }],
v1.data,
() => undefined,
);
assert.equal(items[0]?.name, "Evilname");
assert.equal(items[0]?.description, "abc");
});
test("buildCatalog тянет оба листинга параллельно", async () => {
let inflight = 0;
let maxInflight = 0;
const track = async <T>(v: T): Promise<T> => {
inflight += 1;
maxInflight = Math.max(maxInflight, inflight);
await new Promise((r) => setTimeout(r, 5));
inflight -= 1;
return v;
};
const client = {
getApiModels: () => track(api),
getV1Models: () => track(v1),
} as unknown as Client;
const items = await buildCatalog(client);
assert.equal(items.length, api.data.length);
assert.equal(maxInflight, 2);
});