Клиент — замороженный объект с семью методами 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>
106 lines
4.8 KiB
TypeScript
106 lines
4.8 KiB
TypeScript
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);
|
||
});
|