Добавь 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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user