Заведи каркас пакета и утилитный слой MCP-сервера

package.json, tsconfig, eslint, генерация типов из OpenAPI-спеки,
модули config/redact/errors/money/sanitize/cache с тестами и
обезличенные фикстуры живых ответов API openrusrouter.ru.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vasyanskandClaude Opus 5.5 committed 2026-10-24 10:48:00 +07:00
commit ade6eb82df
29 files changed
+11350

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.tgz
.env
.DS_Store
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Realmanual
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19
View File
@@ -0,0 +1,19 @@
// @ts-check
import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist/**", "node_modules/**", "src/generated/**"] },
js.configs.recommended,
...tseslint.configs.strict,
{
rules: {
// stdout занят протоколом MCP (stdio): любая печать туда ломает сессию.
"no-console": ["error", { allow: ["error"] }],
},
},
{
files: ["scripts/**/*.mjs"],
languageOptions: { globals: { process: "readonly", console: "readonly", URL: "readonly" } },
},
);
+3302
View File
File diff suppressed because it is too large. Load diff
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@openrusrouter/mcp",
"version": "0.1.0",
"description": "MCP-сервер (stdio) для площадки openrusrouter.ru: баланс, каталог моделей, ключи, расход",
"type": "module",
"bin": {
"openrusrouter-mcp": "dist/index.js"
},
"files": [
"dist",
"README.md",
"LICENSE",
"CHANGELOG.md"
],
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://git.realmanual.ru/pub/openrusrouter-mcp.git"
},
"homepage": "https://git.realmanual.ru/pub/openrusrouter-mcp",
"keywords": [
"mcp",
"openrusrouter",
"openrouter",
"llm"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json",
"test": "node --import tsx --test \"test/**/*.test.ts\"",
"lint": "eslint .",
"gen:types": "node scripts/gen-types.mjs",
"prepublishOnly": "npm run typecheck && npm test && npm run build",
"pack:check": "npm pack --dry-run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.32.2",
"zod": "^4.6.7"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^20.19.43",
"eslint": "^10.13.0",
"openapi-typescript": "^7.13.0",
"tsx": "^4.23.15",
"typescript": "5.9.3",
"typescript-eslint": "^8.72.0"
}
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
// Генерирует типы TypeScript из публичной OpenAPI-спеки openrusrouter.ru.
// Источник переопределяется переменной SPEC_URL (например, локальный стенд).
// Результат коммитится: сборка пакета не должна зависеть от сети.
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import openapiTS, { astToString } from "openapi-typescript";
const DEFAULT_SPEC_URL = "https://openrusrouter.ru/docs/spec.json";
const specUrl = new URL(process.env.SPEC_URL || DEFAULT_SPEC_URL);
if (specUrl.protocol !== "https:" && specUrl.protocol !== "file:" &&
!(specUrl.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(specUrl.hostname))) {
console.error(`gen-types: недопустимый SPEC_URL (${specUrl.protocol}//${specUrl.host}) — нужен https или http://localhost`);
process.exit(1);
}
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const out = resolve(root, "src/generated/openapi.d.ts");
const ast = await openapiTS(specUrl);
const header =
"/**\n" +
" * Сгенерировано scripts/gen-types.mjs из OpenAPI-спеки openrusrouter.ru — не править руками.\n" +
" * Внимание: спека расходится с реальными ответами в части полей (см. test/fixtures);\n" +
" * источник правды о форме ответа — фикстуры живых ответов, а не эти типы.\n" +
" */\n";
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, header + astToString(ast));
console.error(`gen-types: записано ${out}`);
+31
View File
@@ -0,0 +1,31 @@
// Кэш одного значения с TTL и single-flight: параллельные вызовы до
// завершения загрузки ждут один и тот же промис, ошибка не кэшируется.
export function createTtlCache<T>(
ttlMs: number,
loader: () => Promise<T>,
now: () => number = Date.now,
): () => Promise<T> {
let value: { data: T; expiresAt: number } | undefined;
let inflight: Promise<T> | undefined;
return function get(): Promise<T> {
if (value !== undefined && now() < value.expiresAt) {
return Promise.resolve(value.data);
}
if (inflight !== undefined) {
return inflight;
}
const p = (async () => {
try {
const data = await loader();
value = { data, expiresAt: now() + ttlMs };
return data;
} finally {
inflight = undefined;
}
})();
inflight = p;
return p;
};
}
+85
View File
@@ -0,0 +1,85 @@
// Конфигурация процесса MCP-сервера. Читается один раз на старте.
//
// Ключ клиента уезжает в заголовке Authorization на base URL, поэтому
// адрес проверяется строже, чем «лишь бы парсился»: незашифрованный http
// допустим только до локального стенда, а userinfo в адресе — отказ сразу
// (такой URL печатается в логах и сообщениях об ошибках целиком).
export const DEFAULT_BASE_URL = "https://openrusrouter.ru";
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
export interface Config {
readonly apiKey: string;
readonly baseUrl: string;
}
/** Ошибка конфигурации. Сообщение НИКОГДА не содержит значения ключа. */
export class ConfigError extends Error {
override name = "ConfigError";
}
export type Env = Readonly<Record<string, string | undefined>>;
/**
* Чистый разбор конфигурации без побочных эффектов (без exit и stderr) —
* отдельно от loadConfig, чтобы его можно было проверить тестом.
*/
export function parseConfig(env: Env): Config {
const apiKey = (env.OPENRUSROUTER_API_KEY ?? "").trim();
if (apiKey === "") {
throw new ConfigError(
"не задана переменная окружения OPENRUSROUTER_API_KEY — укажите API-ключ из личного кабинета openrusrouter.ru",
);
}
const rawBase = (env.OPENRUSROUTER_BASE_URL ?? "").trim();
const baseUrl = rawBase === "" ? DEFAULT_BASE_URL : normalizeBaseUrl(rawBase);
return Object.freeze({ apiKey, baseUrl });
}
function normalizeBaseUrl(raw: string): string {
let url: URL;
try {
url = new URL(raw);
} catch {
// Сам адрес в сообщение не кладём: в нём может оказаться что угодно.
throw new ConfigError("OPENRUSROUTER_BASE_URL не является корректным URL");
}
if (url.username !== "" || url.password !== "") {
throw new ConfigError(
"OPENRUSROUTER_BASE_URL не должен содержать логин/пароль (user:pass@) — уберите их из адреса",
);
}
if (url.protocol === "http:") {
if (!LOCAL_HOSTS.has(url.hostname)) {
throw new ConfigError(
`OPENRUSROUTER_BASE_URL: http разрешён только для localhost/127.0.0.1/::1, для ${url.hostname} нужен https`,
);
}
} else if (url.protocol !== "https:") {
throw new ConfigError(`OPENRUSROUTER_BASE_URL: неподдерживаемый протокол ${url.protocol}`);
}
if (url.search !== "" || url.hash !== "") {
throw new ConfigError("OPENRUSROUTER_BASE_URL не должен содержать query-параметры или фрагмент");
}
return `${url.origin}${url.pathname}`.replace(/\/+$/, "");
}
/**
* Читает конфигурацию из process.env. При ошибке пишет причину в stderr
* (stdout занят протоколом MCP) и завершает процесс с кодом 1.
*/
export function loadConfig(env: Env = process.env): Config {
let config: Config;
try {
config = parseConfig(env);
} catch (err) {
const message = err instanceof ConfigError ? err.message : "ошибка чтения конфигурации";
console.error(`openrusrouter-mcp: ${message}`);
process.exit(1);
}
if (config.baseUrl !== DEFAULT_BASE_URL) {
console.error(`openrusrouter-mcp: ключ будет отправляться на ${new URL(config.baseUrl).host}`);
}
return config;
}
+47
View File
@@ -0,0 +1,47 @@
import { redactError } from "./redact.js";
/** Неуспешный HTTP-ответ API площадки. */
export class ApiError extends Error {
override name = "ApiError";
readonly status: number;
readonly code?: string;
readonly path: string;
constructor(status: number, path: string, message?: string, code?: string) {
super(message ?? `HTTP ${status}`);
this.status = status;
this.path = path;
if (code !== undefined) {
this.code = code;
}
}
}
export interface ToolErrorResult {
[key: string]: unknown;
isError: true;
content: [{ type: "text"; text: string }];
}
function textForStatus(status: number | undefined, fallback: string): string {
if (status === 401) return "ключ недействителен или отозван";
if (status === 403) return "ключу запрещено выполнять это действие";
if (status === 429) return "превышен лимит частоты запросов, повторите позже";
if (status !== undefined && status >= 500 && status <= 599) {
return "сервис openrusrouter.ru временно недоступен";
}
return fallback;
}
/** Ошибку любого вида превращает в результат инструмента MCP; текст всегда проходит redactError. */
export function toToolErrorResult(err: unknown): ToolErrorResult {
const r = redactError(err);
let text = textForStatus(r.status, r.message);
const details = [r.status !== undefined ? `HTTP ${r.status}` : "", r.code ?? "", r.path ?? ""]
.filter((s) => s !== "")
.join(", ");
if (details !== "") {
text += ` (${details})`;
}
return { isError: true, content: [{ type: "text", text }] };
}
+6435
View File
File diff suppressed because it is too large. Load diff
+117
View File
@@ -0,0 +1,117 @@
// Денежная арифметика без float. API отдаёт суммы decimal-строками
// (shopspring/decimal на бэкенде); здесь они живут как целое × 10^-scale.
// number для денег не используется нигде — ни на входе, ни внутри.
export interface Decimal {
readonly int: bigint;
readonly scale: number;
}
const DECIMAL_RE = /^(-?)(\d+)(?:\.(\d+))?$/;
function assertScale(scale: number): void {
if (!Number.isSafeInteger(scale) || scale < 0) {
throw new RangeError(`недопустимый scale: ${String(scale)}`);
}
}
function pow10(n: number): bigint {
assertScale(n);
return 10n ** BigInt(n);
}
function make(int: bigint, scale: number): Decimal {
assertScale(scale);
return Object.freeze({ int, scale });
}
/** Строгий разбор decimal-строки: без экспоненты, без запятой, без пробелов, только string. */
export function parseDecimal(s: unknown): Decimal {
if (typeof s !== "string") {
throw new TypeError(`ожидалась decimal-строка, получено ${typeof s}`);
}
const m = DECIMAL_RE.exec(s);
if (m === null) {
throw new SyntaxError(`некорректная decimal-строка: ${JSON.stringify(s.slice(0, 40))}`);
}
const [, sign = "", whole = "", frac = ""] = m;
const int = BigInt(whole + frac);
return make(sign === "-" ? -int : int, frac.length);
}
export function fromBigInt(n: bigint): Decimal {
return make(n, 0);
}
function rescale(a: Decimal, scale: number): bigint {
// Только повышение точности — без потерь.
return a.int * pow10(scale - a.scale);
}
export function add(a: Decimal, b: Decimal): Decimal {
const scale = Math.max(a.scale, b.scale);
return make(rescale(a, scale) + rescale(b, scale), scale);
}
export function mul(a: Decimal, b: Decimal): Decimal {
return make(a.int * b.int, a.scale + b.scale);
}
export function mulByBigInt(a: Decimal, n: bigint): Decimal {
return make(a.int * n, a.scale);
}
/** Деление на 10^p — точное, просто сдвиг scale. */
export function divPow10(a: Decimal, p: number): Decimal {
assertScale(p);
return make(a.int, a.scale + p);
}
/** ROUND HALF UP (половина — от нуля), как decimal.Round в shopspring. */
export function round(a: Decimal, scale: number): Decimal {
assertScale(scale);
if (a.scale <= scale) {
return make(rescale(a, scale), scale);
}
const divisor = pow10(a.scale - scale);
const negative = a.int < 0n;
const abs = negative ? -a.int : a.int;
let q = abs / divisor;
const r = abs % divisor;
if (r * 2n >= divisor) {
q += 1n;
}
return make(negative ? -q : q, scale);
}
export function compare(a: Decimal, b: Decimal): -1 | 0 | 1 {
const scale = Math.max(a.scale, b.scale);
const x = rescale(a, scale);
const y = rescale(b, scale);
return x < y ? -1 : x > y ? 1 : 0;
}
function format(int: bigint, scale: number): string {
const negative = int < 0n;
const digits = (negative ? -int : int).toString().padStart(scale + 1, "0");
const whole = digits.slice(0, digits.length - scale);
const frac = digits.slice(digits.length - scale);
const body = scale > 0 ? `${whole}.${frac}` : whole;
return negative ? `-${body}` : body;
}
/** Канонический вид: без незначащих нулей справа; "0.00" и "-0" дают "0". */
export function toString(a: Decimal): string {
let { int, scale } = a;
while (scale > 0 && int % 10n === 0n) {
int /= 10n;
scale -= 1;
}
return format(int, scale);
}
/** Ровно scale знаков после точки, с округлением HALF UP. */
export function toFixed(a: Decimal, scale: number): string {
const r = round(a, scale);
return format(r.int, r.scale);
}
+52
View File
@@ -0,0 +1,52 @@
// Вычистка секретов из текста и ошибок перед тем, как они уйдут в stderr
// или в ответ инструмента (а оттуда — в контекст модели и её логи).
/** Ключи площадки: "orr_live_" + 32 символа [A-Za-z0-9] (backend/internal/apikey). */
// Без \b слева: ключ, приклеенный к соседнему слову («keyorr_live_…»), тоже ключ.
const KEY_RE = /orr_[a-z]+_[A-Za-z0-9]+/g;
const BEARER_RE = /\bBearer\s+\S+/gi;
export const REDACTED = "[REDACTED]";
export function redactString(s: string): string {
return s.replace(BEARER_RE, `Bearer ${REDACTED}`).replace(KEY_RE, REDACTED);
}
export interface RedactedError {
name: string;
message: string;
status?: number;
code?: string;
path?: string;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null;
}
/**
* Достаёт из произвольной ошибки только узкий набор полей. headers, config,
* request, cause и stack не копируются никогда: именно в них у http-клиентов
* лежит Authorization.
*/
export function redactError(err: unknown): RedactedError {
if (typeof err === "string") {
return { name: "Error", message: redactString(err) };
}
if (!isRecord(err)) {
return { name: "Error", message: "неизвестная ошибка" };
}
const name = typeof err.name === "string" && err.name !== "" ? err.name : "Error";
const message = typeof err.message === "string" ? err.message : "неизвестная ошибка";
const out: RedactedError = { name: redactString(name), message: redactString(message) };
if (typeof err.status === "number" && Number.isInteger(err.status)) {
out.status = err.status;
}
if (typeof err.code === "string") {
out.code = redactString(err.code);
}
if (typeof err.path === "string") {
out.path = redactString(err.path);
}
return out;
}
+33
View File
@@ -0,0 +1,33 @@
// Очистка текста, пришедшего извне (названия и описания моделей, имена
// ключей), перед тем как он попадёт в ответ инструмента и в контекст модели:
// управляющие символы, bidi-override и zero-width позволяют прятать или
// переворачивать текст так, что человек и модель читают разное.
// C0 (кроме \n), DEL и C1.
// eslint-disable-next-line no-control-regex -- ровно эти символы и вычищаем
const CONTROL_RE = /[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/g;
// Bidi: LRM/RLM, ALM, embedding/override (U+202A–U+202E), isolates (U+2066–U+2069).
const BIDI_RE = /[\u200E\u200F\u061C\u202A-\u202E\u2066-\u2069]/g;
// Zero-width: ZWSP/ZWNJ/ZWJ, word joiner, BOM.
const ZERO_WIDTH_RE = /[\u200B-\u200D\u2060\uFEFF]/g;
export function cleanText(s: string, max = 500): string {
const cleaned = s
.replace(/\r\n?|[\u2028\u2029]/g, "\n")
.replace(/\t/g, " ")
.replace(CONTROL_RE, "")
.replace(BIDI_RE, "")
.replace(ZERO_WIDTH_RE, "")
.replace(/[ \u00A0]+/g, " ")
.replace(/ *\n[ \n]*/g, "\n")
.trim();
// Режем по кодовым точкам, а не по UTF-16, чтобы не разорвать суррогатную пару.
const chars = Array.from(cleaned);
if (chars.length <= max) {
return cleaned;
}
if (max <= 0) {
return "";
}
return `${chars.slice(0, max - 1).join("").trimEnd()}…`;
}
+42
View File
@@ -0,0 +1,42 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createTtlCache } from "../src/cache.js";
test("значение живёт ttl и потом перезагружается", async () => {
let t = 1_000;
let calls = 0;
const get = createTtlCache(100, async () => ++calls, () => t);
assert.equal(await get(), 1);
t += 99;
assert.equal(await get(), 1);
t += 1;
assert.equal(await get(), 2);
assert.equal(calls, 2);
});
test("single-flight: один loader на N параллельных get()", async () => {
let calls = 0;
let release!: (v: string) => void;
const get = createTtlCache(1_000, () => {
calls++;
return new Promise<string>((r) => {
release = r;
});
});
const all = Promise.all(Array.from({ length: 10 }, () => get()));
release("ok");
assert.deepEqual(await all, Array(10).fill("ok"));
assert.equal(calls, 1);
});
test("ошибка не кэшируется", async () => {
let calls = 0;
const get = createTtlCache(1_000, async () => {
calls++;
if (calls === 1) throw new Error("fail");
return "ok";
});
await assert.rejects(get(), /fail/);
assert.equal(await get(), "ok");
assert.equal(calls, 2);
});
+72
View File
@@ -0,0 +1,72 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { ConfigError, DEFAULT_BASE_URL, parseConfig } from "../src/config.js";
const KEY = "orr_live_TestKey0123456789abcdefABCDEFGHIJ";
test("нет ключа — ConfigError без значений переменных", () => {
assert.throws(
() => parseConfig({ OPENRUSROUTER_BASE_URL: "https://secret-host.example/x" }),
(err: unknown) => {
assert.ok(err instanceof ConfigError);
assert.match(err.message, /OPENRUSROUTER_API_KEY/);
assert.ok(!err.message.includes("secret-host"));
assert.ok(!err.message.includes("${"));
return true;
},
);
assert.throws(() => parseConfig({ OPENRUSROUTER_API_KEY: " " }), ConfigError);
});
test("по умолчанию — прод, объект заморожен", () => {
const c = parseConfig({ OPENRUSROUTER_API_KEY: KEY });
assert.equal(c.baseUrl, DEFAULT_BASE_URL);
assert.equal(c.apiKey, KEY);
assert.ok(Object.isFrozen(c));
});
test("http на внешний хост — отказ", () => {
assert.throws(
() => parseConfig({ OPENRUSROUTER_API_KEY: KEY, OPENRUSROUTER_BASE_URL: "http://example.com" }),
ConfigError,
);
});
test("userinfo в адресе — отказ, и пароль не попадает в сообщение", () => {
assert.throws(
() =>
parseConfig({
OPENRUSROUTER_API_KEY: KEY,
OPENRUSROUTER_BASE_URL: "https://user:hunter2@openrusrouter.ru",
}),
(err: unknown) => {
assert.ok(err instanceof ConfigError);
assert.ok(!err.message.includes("hunter2"));
return true;
},
);
});
test("мусор и чужие протоколы — отказ", () => {
for (const url of ["not a url", "ftp://openrusrouter.ru", "https://openrusrouter.ru/?k=1"]) {
assert.throws(
() => parseConfig({ OPENRUSROUTER_API_KEY: KEY, OPENRUSROUTER_BASE_URL: url }),
ConfigError,
url,
);
}
});
test("https на любой хост и http на localhost — ок, хвостовой / срезан", () => {
const ok: Array<[string, string]> = [
["https://any-host.example/", "https://any-host.example"],
["https://any-host.example/prefix/", "https://any-host.example/prefix"],
["http://localhost:1234", "http://localhost:1234"],
["http://127.0.0.1:8080/", "http://127.0.0.1:8080"],
["http://[::1]:8080", "http://[::1]:8080"],
];
for (const [input, expected] of ok) {
const c = parseConfig({ OPENRUSROUTER_API_KEY: KEY, OPENRUSROUTER_BASE_URL: input });
assert.equal(c.baseUrl, expected);
}
});
+1
View File
@@ -0,0 +1 @@
{"balance":"1234.56789012","referral_balance":"0"}
+43
View File
@@ -0,0 +1,43 @@
{
"keys": [
{
"id": "6f1c2a9e-4b7d-4e21-9a3c-1d2e3f405a6b",
"name": "my-agent",
"prefix": "orr_live_Ab3dEf",
"last_used_at": "2026-10-24T03:39:57.228771Z",
"created_at": "2026-10-23T02:00:45.11982Z",
"revoked_at": null,
"spend_window": "day",
"spend_limit_rub": "200",
"spend_used": "12.5784961",
"spend_resets_at": "2026-10-25T00:00:00Z",
"allowed_models": null
},
{
"id": "a8b9c0d1-e2f3-4a5b-8c6d-7e8f90a1b2c3",
"name": "restricted-bot",
"prefix": "orr_live_Gh5iJk",
"last_used_at": "2026-10-20T01:36:06.837887Z",
"created_at": "2026-09-22T09:07:45.316979Z",
"revoked_at": null,
"spend_window": "day",
"spend_limit_rub": "100",
"spend_used": "0",
"spend_resets_at": "2026-10-25T00:00:00Z",
"allowed_models": ["openai/gpt-4o-mini"]
},
{
"id": "0d9e8f7a-6b5c-4d3e-a2f1-0b9c8d7e6f5a",
"name": "ci-runner",
"prefix": "orr_live_Lm7nOp",
"last_used_at": "2026-10-02T13:11:04.90802Z",
"created_at": "2026-10-02T02:37:36.886285Z",
"revoked_at": null,
"spend_window": "day",
"spend_limit_rub": "1000",
"spend_used": "0",
"spend_resets_at": "2026-10-25T00:00:00Z",
"allowed_models": null
}
]
}
+254
View File
@@ -0,0 +1,254 @@
{
"data": [
{
"id": "aion-labs/aion-3.0-mini",
"name": "AionLabs: Aion-3.0-Mini",
"description": "Aion-3.0 Mini is a multi-model roleplaying and storytelling system from AionLabs, built on the DeepSeek family of models. It uses a collaborative generation process in which multiple specialized models each...",
"context_length": 131072,
"price_prompt_rub_1m": "79.91392973",
"price_completion_rub_1m": "159.82785945",
"is_free": false,
"modality": "text",
"input_modalities": [
"text"
],
"price_image_rub": "0"
},
{
"id": "anthropic/claude-sonnet-5.5",
"name": "Anthropic: Claude Sonnet 5.5",
"description": "Claude Sonnet 5.5 is Anthropic's Sonnet-class model for well-scoped everyday work, succeeding Claude Sonnet 5 as a direct upgrade. It is especially strong at building features, fixing bugs, and producing...",
"context_length": 1000000,
"price_prompt_rub_1m": "228.3255135",
"price_completion_rub_1m": "1141.6275675",
"is_free": false,
"modality": "text",
"input_modalities": [
"text",
"image",
"file"
],
"price_image_rub": "0"
},
{
"id": "assemblyai/universal-3-5-pro",
"name": "AssemblyAI: Universal-3.5 Pro",
"description": "Universal-3.5 Pro is AssemblyAI's speech-to-text model served through its Sync API, returning a complete transcript with word-level timestamps in a single synchronous response for audio up to 20 minutes. It...",
"context_length": 0,
"price_prompt_rub_1m": "9513.55925707",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "transcription",
"input_modalities": [
"audio"
],
"price_image_rub": "0"
},
{
"id": "baai/bge-m3",
"name": "BAAI: bge-m3",
"description": "The bge-m3 embedding model encodes sentences, paragraphs, and long documents into a 1024-dimensional dense vector space, delivering high-quality semantic embeddings optimized for multilingual retrieval, semantic search, and large-context applications.",
"context_length": 8194,
"price_prompt_rub_1m": "1.14162757",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "embedding",
"input_modalities": [
"text"
],
"price_image_rub": "0"
},
{
"id": "black-forest-labs/flux-3-image",
"name": "Black Forest Labs: FLUX 3 Image",
"description": "FLUX.3 Image is Black Forest Labs' flagship image generation and editing model. It handles text-to-image and multi-reference editing with up to 10 input images, and renders at fixed resolution tiers...",
"context_length": 0,
"price_prompt_rub_1m": "0",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "image",
"input_modalities": [
"text",
"image"
],
"price_image_rub": "0"
},
{
"id": "bytedance-seed/seedream-4.5",
"name": "ByteDance Seed: Seedream 4.5",
"description": "Seedream 4.5 is the latest in-house image generation model developed by ByteDance. Compared with Seedream 4.0, it delivers comprehensive improvements, especially in editing consistency, including better preservation of subject details,...",
"context_length": 0,
"price_prompt_rub_1m": "0",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "image",
"input_modalities": [
"image",
"text"
],
"price_image_rub": "4.56651027",
"price_image_unit": "image"
},
{
"id": "deepseek/deepseek-v4-flash",
"name": "DeepSeek: DeepSeek V4 Flash 0423",
"description": "DeepSeek V4 Flash is an efficiency-optimized Mixture-of-Experts model from DeepSeek with 284B total parameters and 13B activated parameters, supporting a 1M-token context window. It is designed for fast inference and...",
"context_length": 1048576,
"price_prompt_rub_1m": "10.38881086",
"price_completion_rub_1m": "20.77762173",
"is_free": false,
"modality": "text",
"input_modalities": [
"text"
],
"price_image_rub": "0"
},
{
"id": "elevenlabs/eleven-flash-v2",
"name": "ElevenLabs: Eleven Flash v2",
"description": "Eleven Flash v2 is an English-only, ultra-low-latency text-to-speech model from ElevenLabs. It is suited for conversational and real-time English use cases, and has a 30,000-character request limit.",
"context_length": 0,
"price_prompt_rub_1m": "4566.51027",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "speech",
"input_modalities": [
"text"
],
"price_image_rub": "0"
},
{
"id": "elevenlabs/eleven-multilingual-v2",
"name": "ElevenLabs: Eleven Multilingual v2",
"description": "Eleven Multilingual v2 is a text-to-speech model from ElevenLabs. It is suited for lifelike, consistent long-form narration such as voice-overs and audiobooks, supports 29 languages, and has a 10,000-character request...",
"context_length": 0,
"price_prompt_rub_1m": "9133.02054",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "speech",
"input_modalities": [
"text"
],
"price_image_rub": "0"
},
{
"id": "google/gemini-2.5-flash-image",
"name": "Google: Nano Banana (Gemini 2.5 Flash Image)",
"description": "Gemini 2.5 Flash Image, a.k.a. \"Nano Banana,\" is now generally available. It is a state of the art image generation model with contextual understanding. It is capable of image generation,...",
"context_length": 0,
"price_prompt_rub_1m": "0",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "image",
"input_modalities": [
"image",
"text"
],
"price_image_rub": "3424.8827025",
"price_image_unit": "token"
},
{
"id": "nvidia/nemotron-3.5-embed-1b:free",
"name": "NVIDIA: Nemotron 3.5 Embed 1B (free)",
"description": "Nemotron 3.5 Embed 1B is a multimodal embedding model from NVIDIA for multilingual retrieval over text and document page images (tables, charts, infographics). Queries are text and documents can be...",
"context_length": 32768,
"price_prompt_rub_1m": "0",
"price_completion_rub_1m": "0",
"is_free": true,
"modality": "embedding",
"input_modalities": [
"text",
"image"
],
"price_image_rub": "0"
},
{
"id": "nvidia/nemotron-3.5-super-vl:free",
"name": "NVIDIA: Nemotron 3.5 Super VL (free)",
"description": "NVIDIA Nemotron 3.5 Super VL is an open multimodal mixture-of-experts model from NVIDIA, with 12B active parameters out of 120B total. It takes text, images, documents, GUI screenshots, and video...",
"context_length": 1000000,
"price_prompt_rub_1m": "0",
"price_completion_rub_1m": "0",
"is_free": true,
"modality": "text",
"input_modalities": [
"text",
"image",
"video"
],
"price_image_rub": "0"
},
{
"id": "openai/gpt-6-luna",
"name": "OpenAI: GPT-6 Luna",
"description": "GPT-6 Luna is the fast, cost-efficient model in OpenAI's GPT-6 series, positioned below GPT-6 Sol. It is suited for high-volume and latency-sensitive workloads such as chat, classification, and lightweight agentic...",
"context_length": 1050000,
"price_prompt_rub_1m": "11.41627568",
"price_completion_rub_1m": "57.08137838",
"is_free": false,
"modality": "text",
"input_modalities": [
"file",
"image",
"text"
],
"price_image_rub": "0"
},
{
"id": "openai/whisper-1",
"name": "OpenAI: Whisper 1",
"description": "Whisper is OpenAI's open-source automatic speech recognition model, available via API as `whisper-1`. It supports transcription and translation across 50+ languages from audio files up to 25 MB. Accepts formats...",
"context_length": 0,
"price_prompt_rub_1m": "11416.275675",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "transcription",
"input_modalities": [
"audio"
],
"price_image_rub": "0"
},
{
"id": "qwen/qwen3-coder-30b-a3b-instruct",
"name": "Qwen: Qwen3 Coder 30B A3B Instruct",
"description": "Qwen3-Coder-30B-A3B-Instruct is a 30.5B parameter Mixture-of-Experts (MoE) model with 128 experts (8 active per forward pass), designed for advanced code generation, repository-scale understanding, and agentic tool use. Built on the...",
"context_length": 262144,
"price_prompt_rub_1m": "7.99139297",
"price_completion_rub_1m": "31.96557189",
"is_free": false,
"modality": "text",
"input_modalities": [
"text"
],
"price_image_rub": "0"
},
{
"id": "typesafe/jev-1.13.0",
"name": "Jev 1.13",
"description": "Отвечает не текстом, а калиброванной вероятностью на типизированный вопрос о поданном тексте: да/нет, выбор из вариантов, оценка по шкале. Маршрут POST /v1/systemone — это не чат, поля messages у него нет. Основной язык модели — английский, остальные языки поддержаны заметно хуже: проверяйте качество на своей выборке. Известные ограничения провайдера: модель не считает, не сравнивает даты, не решает задач, требующих числовой точности, читает инструкции буквально и не генерирует текст.",
"context_length": 64000,
"price_prompt_rub_1m": "4.79483578",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "decision",
"input_modalities": [
"text"
],
"price_image_rub": "0"
},
{
"id": "typesafe/jev-latest",
"name": "Jev (последняя версия)",
"description": "Плавающий алиас: провайдер обслуживает им текущую версию Jev. Цена совпадает с закреплённой версией. Отвечает не текстом, а калиброванной вероятностью на типизированный вопрос о поданном тексте: да/нет, выбор из вариантов, оценка по шкале. Маршрут POST /v1/systemone — это не чат, поля messages у него нет. Основной язык модели — английский, остальные языки поддержаны заметно хуже: проверяйте качество на своей выборке. Известные ограничения провайдера: модель не считает, не сравнивает даты, не решает задач, требующих числовой точности, читает инструкции буквально и не генерирует текст.",
"context_length": 64000,
"price_prompt_rub_1m": "4.79483578",
"price_completion_rub_1m": "0",
"is_free": false,
"modality": "decision",
"input_modalities": [
"text"
],
"price_image_rub": "0"
}
]
}
+1
View File
@@ -0,0 +1 @@
{"data":[{"model":"deepseek/deepseek-v4-flash","requests":5,"tokens_prompt":51230,"tokens_cached":0,"tokens_completion":11874,"amount_rub":"0.8124417"},{"model":"elevenlabs/eleven-flash-v2","requests":3,"tokens_prompt":15,"tokens_cached":0,"tokens_completion":0,"amount_rub":"0.18412076"},{"model":"openai/whisper-1","requests":2,"tokens_prompt":0,"tokens_cached":0,"tokens_completion":0,"amount_rub":"0.24310558"},{"model":"openai/gpt-6-luna","requests":2,"tokens_prompt":1022,"tokens_cached":256,"tokens_completion":611,"amount_rub":"0.05120934"},{"model":"qwen/qwen3-coder-30b-a3b-instruct","requests":1,"tokens_prompt":301,"tokens_cached":0,"tokens_completion":38,"amount_rub":"0.00371284"}],"failed_events":0,"group_by":"model","from":"2026-10-17T03:39:57Z","to":"2026-10-24T03:39:57Z"}
+72
View File
@@ -0,0 +1,72 @@
{
"models": [
{
"id": "aion-labs/aion-3.0-mini",
"name": "AionLabs: Aion-3.0-Mini"
},
{
"id": "anthropic/claude-sonnet-5.5",
"name": "Anthropic: Claude Sonnet 5.5"
},
{
"id": "assemblyai/universal-3-5-pro",
"name": "AssemblyAI: Universal-3.5 Pro"
},
{
"id": "baai/bge-m3",
"name": "BAAI: bge-m3"
},
{
"id": "black-forest-labs/flux-3-image",
"name": "Black Forest Labs: FLUX 3 Image"
},
{
"id": "bytedance-seed/seedream-4.5",
"name": "ByteDance Seed: Seedream 4.5"
},
{
"id": "deepseek/deepseek-v4-flash",
"name": "DeepSeek: DeepSeek V4 Flash 0423"
},
{
"id": "elevenlabs/eleven-flash-v2",
"name": "ElevenLabs: Eleven Flash v2"
},
{
"id": "elevenlabs/eleven-multilingual-v2",
"name": "ElevenLabs: Eleven Multilingual v2"
},
{
"id": "google/gemini-2.5-flash-image",
"name": "Google: Nano Banana (Gemini 2.5 Flash Image)"
},
{
"id": "nvidia/nemotron-3.5-embed-1b:free",
"name": "NVIDIA: Nemotron 3.5 Embed 1B (free)"
},
{
"id": "nvidia/nemotron-3.5-super-vl:free",
"name": "NVIDIA: Nemotron 3.5 Super VL (free)"
},
{
"id": "openai/gpt-6-luna",
"name": "OpenAI: GPT-6 Luna"
},
{
"id": "openai/whisper-1",
"name": "OpenAI: Whisper 1"
},
{
"id": "qwen/qwen3-coder-30b-a3b-instruct",
"name": "Qwen: Qwen3 Coder 30B A3B Instruct"
},
{
"id": "typesafe/jev-1.13.0",
"name": "Jev 1.13"
},
{
"id": "typesafe/jev-latest",
"name": "Jev (последняя версия)"
}
]
}
+1
View File
@@ -0,0 +1 @@
{"data":{"label":"my-agent","balance":"187.42150390","account_balance":"1234.56789012","limit":"200","limit_remaining":"187.42150390","usage":"12.57849610","limit_window":"day","limit_resets_at":"2026-10-25T00:00:00Z"}}
+436
View File
@@ -0,0 +1,436 @@
{
"object": "list",
"data": [
{
"id": "aion-labs/aion-3.0-mini",
"object": "model",
"created": 1792811344,
"owned_by": "aion-labs",
"name": "AionLabs: Aion-3.0-Mini",
"description": "Aion-3.0 Mini is a multi-model roleplaying and storytelling system from AionLabs, built on the DeepSeek family of models. It uses a collaborative generation process in which multiple specialized models each...",
"context_length": 131072,
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"modality": "text->text"
},
"pricing": {
"prompt": "0.00000099225",
"completion": "0.0000019845",
"request": "0",
"image": "0",
"input_cache_read": "0.00000025515"
},
"top_provider": {
"context_length": 131072,
"is_moderated": false
}
},
{
"id": "anthropic/claude-sonnet-5.5",
"object": "model",
"created": 1792811344,
"owned_by": "anthropic",
"name": "Anthropic: Claude Sonnet 5.5",
"description": "Claude Sonnet 5.5 is Anthropic's Sonnet-class model for well-scoped everyday work, succeeding Claude Sonnet 5 as a direct upgrade. It is especially strong at building features, fixing bugs, and producing...",
"context_length": 1000000,
"architecture": {
"input_modalities": [
"text",
"image",
"file"
],
"output_modalities": [
"text"
],
"modality": "text+image+file->text"
},
"pricing": {
"prompt": "0.000002835",
"completion": "0.000014175",
"request": "0",
"image": "0",
"input_cache_read": "0.00000014175"
},
"top_provider": {
"context_length": 1000000,
"is_moderated": false
}
},
{
"id": "assemblyai/universal-3-5-pro",
"object": "model",
"created": 1792811344,
"owned_by": "assemblyai",
"name": "AssemblyAI: Universal-3.5 Pro",
"description": "Universal-3.5 Pro is AssemblyAI's speech-to-text model served through its Sync API, returning a complete transcript with word-level timestamps in a single synchronous response for audio up to 20 minutes. It...",
"architecture": {
"input_modalities": [
"audio"
],
"output_modalities": [
"text"
],
"modality": "audio->text"
},
"top_provider": {
"is_moderated": false
}
},
{
"id": "baai/bge-m3",
"object": "model",
"created": 1792811344,
"owned_by": "baai",
"name": "BAAI: bge-m3",
"description": "The bge-m3 embedding model encodes sentences, paragraphs, and long documents into a 1024-dimensional dense vector space, delivering high-quality semantic embeddings optimized for multilingual retrieval, semantic search, and large-context applications.",
"context_length": 8194,
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"embeddings"
],
"modality": "text->embeddings"
},
"pricing": {
"prompt": "0.000000014175",
"completion": "0",
"request": "0",
"image": "0"
},
"top_provider": {
"context_length": 8194,
"is_moderated": false
}
},
{
"id": "black-forest-labs/flux-3-image",
"object": "model",
"created": 1792811344,
"owned_by": "black-forest-labs",
"name": "Black Forest Labs: FLUX 3 Image",
"description": "FLUX.3 Image is Black Forest Labs' flagship image generation and editing model. It handles text-to-image and multi-reference editing with up to 10 input images, and renders at fixed resolution tiers...",
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"image"
],
"modality": "text->image"
},
"top_provider": {
"is_moderated": false
}
},
{
"id": "bytedance-seed/seedream-4.5",
"object": "model",
"created": 1792811344,
"owned_by": "bytedance-seed",
"name": "ByteDance Seed: Seedream 4.5",
"description": "Seedream 4.5 is the latest in-house image generation model developed by ByteDance. Compared with Seedream 4.0, it delivers comprehensive improvements, especially in editing consistency, including better preservation of subject details,...",
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"image"
],
"modality": "text->image"
},
"top_provider": {
"is_moderated": false
}
},
{
"id": "deepseek/deepseek-v4-flash",
"object": "model",
"created": 1792811344,
"owned_by": "deepseek",
"name": "DeepSeek: DeepSeek V4 Flash 0423",
"description": "DeepSeek V4 Flash is an efficiency-optimized Mixture-of-Experts model from DeepSeek with 284B total parameters and 13B activated parameters, supporting a 1M-token context window. It is designed for fast inference and...",
"context_length": 1048576,
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"modality": "text->text"
},
"pricing": {
"prompt": "0.0000001289925",
"completion": "0.000000257985",
"request": "0",
"image": "0",
"input_cache_read": "0.0000000257985"
},
"top_provider": {
"context_length": 1048576,
"is_moderated": false
}
},
{
"id": "elevenlabs/eleven-flash-v2",
"object": "model",
"created": 1792811344,
"owned_by": "elevenlabs",
"name": "ElevenLabs: Eleven Flash v2",
"description": "Eleven Flash v2 is an English-only, ultra-low-latency text-to-speech model from ElevenLabs. It is suited for conversational and real-time English use cases, and has a 30,000-character request limit.",
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"speech"
],
"modality": "text->speech"
},
"top_provider": {
"is_moderated": false
}
},
{
"id": "elevenlabs/eleven-multilingual-v2",
"object": "model",
"created": 1792811344,
"owned_by": "elevenlabs",
"name": "ElevenLabs: Eleven Multilingual v2",
"description": "Eleven Multilingual v2 is a text-to-speech model from ElevenLabs. It is suited for lifelike, consistent long-form narration such as voice-overs and audiobooks, supports 29 languages, and has a 10,000-character request...",
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"speech"
],
"modality": "text->speech"
},
"top_provider": {
"is_moderated": false
}
},
{
"id": "google/gemini-2.5-flash-image",
"object": "model",
"created": 1792811344,
"owned_by": "google",
"name": "Google: Nano Banana (Gemini 2.5 Flash Image)",
"description": "Gemini 2.5 Flash Image, a.k.a. \"Nano Banana,\" is now generally available. It is a state of the art image generation model with contextual understanding. It is capable of image generation,...",
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"image"
],
"modality": "text->image"
},
"top_provider": {
"is_moderated": false
}
},
{
"id": "nvidia/nemotron-3.5-embed-1b:free",
"object": "model",
"created": 1792811344,
"owned_by": "nvidia",
"name": "NVIDIA: Nemotron 3.5 Embed 1B (free)",
"description": "Nemotron 3.5 Embed 1B is a multimodal embedding model from NVIDIA for multilingual retrieval over text and document page images (tables, charts, infographics). Queries are text and documents can be...",
"context_length": 32768,
"architecture": {
"input_modalities": [
"text",
"image"
],
"output_modalities": [
"embeddings"
],
"modality": "text+image->embeddings"
},
"pricing": {
"prompt": "0",
"completion": "0",
"request": "0",
"image": "0"
},
"top_provider": {
"context_length": 32768,
"is_moderated": false
}
},
{
"id": "nvidia/nemotron-3.5-super-vl:free",
"object": "model",
"created": 1792811344,
"owned_by": "nvidia",
"name": "NVIDIA: Nemotron 3.5 Super VL (free)",
"description": "NVIDIA Nemotron 3.5 Super VL is an open multimodal mixture-of-experts model from NVIDIA, with 12B active parameters out of 120B total. It takes text, images, documents, GUI screenshots, and video...",
"context_length": 1000000,
"architecture": {
"input_modalities": [
"text",
"image",
"video"
],
"output_modalities": [
"text"
],
"modality": "text+image+video->text"
},
"pricing": {
"prompt": "0",
"completion": "0",
"request": "0",
"image": "0"
},
"top_provider": {
"context_length": 1000000,
"is_moderated": false
}
},
{
"id": "openai/gpt-6-luna",
"object": "model",
"created": 1792811344,
"owned_by": "openai",
"name": "OpenAI: GPT-6 Luna",
"description": "GPT-6 Luna is the fast, cost-efficient model in OpenAI's GPT-6 series, positioned below GPT-6 Sol. It is suited for high-volume and latency-sensitive workloads such as chat, classification, and lightweight agentic...",
"context_length": 1050000,
"architecture": {
"input_modalities": [
"file",
"image",
"text"
],
"output_modalities": [
"text"
],
"modality": "file+image+text->text"
},
"pricing": {
"prompt": "0.00000014175",
"completion": "0.00000070875",
"request": "0",
"image": "0",
"input_cache_read": "0.000000014175"
},
"top_provider": {
"context_length": 1050000,
"is_moderated": false
}
},
{
"id": "openai/whisper-1",
"object": "model",
"created": 1792811344,
"owned_by": "openai",
"name": "OpenAI: Whisper 1",
"description": "Whisper is OpenAI's open-source automatic speech recognition model, available via API as `whisper-1`. It supports transcription and translation across 50+ languages from audio files up to 25 MB. Accepts formats...",
"architecture": {
"input_modalities": [
"audio"
],
"output_modalities": [
"text"
],
"modality": "audio->text"
},
"top_provider": {
"is_moderated": false
}
},
{
"id": "qwen/qwen3-coder-30b-a3b-instruct",
"object": "model",
"created": 1792811344,
"owned_by": "qwen",
"name": "Qwen: Qwen3 Coder 30B A3B Instruct",
"description": "Qwen3-Coder-30B-A3B-Instruct is a 30.5B parameter Mixture-of-Experts (MoE) model with 128 experts (8 active per forward pass), designed for advanced code generation, repository-scale understanding, and agentic tool use. Built on the...",
"context_length": 262144,
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"modality": "text->text"
},
"pricing": {
"prompt": "0.000000099225",
"completion": "0.0000003969",
"request": "0",
"image": "0"
},
"top_provider": {
"context_length": 262144,
"is_moderated": false
}
},
{
"id": "typesafe/jev-1.13.0",
"object": "model",
"created": 1789984193,
"owned_by": "typesafe",
"name": "Jev 1.13",
"description": "Отвечает не текстом, а калиброванной вероятностью на типизированный вопрос о поданном тексте: да/нет, выбор из вариантов, оценка по шкале. Маршрут POST /v1/systemone — это не чат, поля messages у него нет. Основной язык модели — английский, остальные языки поддержаны заметно хуже: проверяйте качество на своей выборке. Известные ограничения провайдера: модель не считает, не сравнивает даты, не решает задач, требующих числовой точности, читает инструкции буквально и не генерирует текст.",
"context_length": 64000,
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"modality": "text->text"
},
"pricing": {
"prompt": "0.000000059535",
"completion": "0",
"request": "0",
"image": "0"
},
"top_provider": {
"context_length": 64000,
"is_moderated": false
}
},
{
"id": "typesafe/jev-latest",
"object": "model",
"created": 1789984193,
"owned_by": "typesafe",
"name": "Jev (последняя версия)",
"description": "Плавающий алиас: провайдер обслуживает им текущую версию Jev. Цена совпадает с закреплённой версией. Отвечает не текстом, а калиброванной вероятностью на типизированный вопрос о поданном тексте: да/нет, выбор из вариантов, оценка по шкале. Маршрут POST /v1/systemone — это не чат, поля messages у него нет. Основной язык модели — английский, остальные языки поддержаны заметно хуже: проверяйте качество на своей выборке. Известные ограничения провайдера: модель не считает, не сравнивает даты, не решает задач, требующих числовой точности, читает инструкции буквально и не генерирует текст.",
"context_length": 64000,
"architecture": {
"input_modalities": [
"text"
],
"output_modalities": [
"text"
],
"modality": "text->text"
},
"pricing": {
"prompt": "0.000000059535",
"completion": "0",
"request": "0",
"image": "0"
},
"top_provider": {
"context_length": 64000,
"is_moderated": false
}
}
]
}
+61
View File
@@ -0,0 +1,61 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
add,
compare,
divPow10,
mul,
mulByBigInt,
parseDecimal,
round,
toFixed,
toString,
} from "../src/money.js";
const d = parseDecimal;
test("0.1 + 0.2 = 0.3", () => {
assert.equal(toString(add(d("0.1"), d("0.2"))), "0.3");
});
test("мелкая цена × много токенов без потери точности", () => {
assert.equal(toString(mulByBigInt(d("0.00000012"), 50_000_000n)), "6");
assert.equal(toString(mulByBigInt(d("0.0000001289925"), 123_456_789n)), "15.9249998550825");
// цена за 1M токенов
assert.equal(toString(divPow10(d("79.91392973"), 6)), "0.00007991392973");
assert.equal(toString(mul(d("1.5"), d("0.25"))), "0.375");
assert.equal(toString(mul(d("-1.5"), d("0.2"))), "-0.3");
});
test("round HALF UP — от нуля на .5", () => {
assert.equal(toString(round(d("0.125"), 2)), "0.13");
assert.equal(toString(round(d("-0.125"), 2)), "-0.13");
assert.equal(toString(round(d("0.124999"), 2)), "0.12");
assert.equal(toString(round(d("2.5"), 0)), "3");
assert.equal(toString(round(d("-2.5"), 0)), "-3");
assert.equal(toString(round(d("0.0005"), 3)), "0.001");
assert.equal(toString(round(d("-0.0004"), 3)), "0");
assert.equal(toFixed(d("1.005"), 2), "1.01");
assert.equal(toFixed(d("7"), 2), "7.00");
assert.equal(toFixed(d("-0.001"), 2), "0.00");
});
test("parseDecimal строгий", () => {
assert.throws(() => parseDecimal(0.1), TypeError);
for (const bad of ["1e-7", "", "1,5", " 1", "1.", ".5", "+1", "--1", "NaN"]) {
assert.throws(() => parseDecimal(bad), SyntaxError, bad);
}
assert.equal(toString(d("-12.3400")), "-12.34");
});
test("compare и канонический toString для нулей и отрицательных", () => {
assert.equal(compare(d("0"), d("0.0")), 0);
assert.equal(compare(d("0.00"), d("-0")), 0);
for (const z of ["0", "0.0", "0.00", "-0", "-0.000"]) {
assert.equal(toString(d(z)), "0", z);
}
assert.equal(compare(d("-1.5"), d("-1.49")), -1);
assert.equal(compare(d("10"), d("9.999")), 1);
assert.equal(toString(d("-0.050")), "-0.05");
assert.equal(toString(d("100")), "100");
});
+72
View File
@@ -0,0 +1,72 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { redactError, redactString } from "../src/redact.js";
import { ApiError, toToolErrorResult } from "../src/errors.js";
const KEY = "orr_live_AbCdEf0123456789GhIjKlMnOpQrStUv";
function assertClean(serialized: string): void {
assert.ok(!serialized.includes(KEY), serialized);
assert.ok(!serialized.includes(`Bearer ${KEY}`), serialized);
assert.ok(!serialized.includes(KEY.slice(9)), serialized);
}
test("redactString маскирует Bearer и ключи", () => {
const out = redactString(`Authorization: Bearer ${KEY}; ключ=${KEY}; x${KEY}`);
assertClean(out);
assert.match(out, /Bearer \[REDACTED\]/);
});
test("ключ в message", () => {
const e = new Error(`request failed for ${KEY}`);
assertClean(JSON.stringify(redactError(e)));
});
test("ключ в cause.message не протекает", () => {
const e = new Error("outer", { cause: new Error(`inner Bearer ${KEY}`) });
const r = redactError(e);
assertClean(JSON.stringify(r));
assert.equal(r.message, "outer");
});
test("headers/config/request/stack не копируются", () => {
const e = Object.assign(new Error("boom"), {
status: 401,
code: "invalid_key",
path: "/api/balance",
headers: { Authorization: `Bearer ${KEY}` },
config: { headers: { Authorization: `Bearer ${KEY}` } },
request: { auth: KEY },
});
e.stack = `Error: ${KEY}\n at x`;
const r = redactError(e);
assertClean(JSON.stringify(r));
assert.deepEqual(Object.keys(r).sort(), ["code", "message", "name", "path", "status"]);
assert.equal(r.status, 401);
});
test("произвольные значения", () => {
assertClean(JSON.stringify(redactError(`string ${KEY}`)));
assertClean(JSON.stringify(redactError({ message: `obj ${KEY}`, headers: { a: KEY } })));
assert.equal(redactError(null).message, "неизвестная ошибка");
assert.equal(redactError(42).message, "неизвестная ошибка");
});
test("toToolErrorResult маппит статусы и редактирует текст", () => {
const cases: Array<[number, RegExp]> = [
[401, /недействителен или отозван/],
[403, /запрещено/],
[429, /лимит частоты/],
[503, /временно недоступен/],
];
for (const [status, re] of cases) {
const r = toToolErrorResult(new ApiError(status, "/api/balance", `oops ${KEY}`));
assert.equal(r.isError, true);
assert.match(r.content[0].text, re);
assertClean(JSON.stringify(r));
}
const other = toToolErrorResult(new ApiError(400, "/api/usage", `bad Bearer ${KEY}`));
assert.match(other.content[0].text, /bad Bearer \[REDACTED\]/);
assertClean(JSON.stringify(other));
assertClean(JSON.stringify(toToolErrorResult(new Error(`plain ${KEY}`))));
});
+20
View File
@@ -0,0 +1,20 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { cleanText } from "../src/sanitize.js";
test("убирает управляющие, bidi и zero-width символы", () => {
assert.equal(cleanText("a\u0000b\u0007c\u009Fd\u007Fe"), "abcde");
assert.equal(cleanText("safe\u202Etxt.exe\u202C"), "safetxt.exe");
assert.equal(cleanText("x\u2066y\u2069z\u200Bw\uFEFF"), "xyzw");
});
test("схлопывает пробелы и переводы строк", () => {
assert.equal(cleanText(" a \t b\r\n\r\n\n c "), "a b\nc");
});
test("обрезает по max с многоточием, не рвёт суррогатные пары", () => {
assert.equal(cleanText("abcdef", 4), "abc…");
assert.equal(cleanText("abcd", 4), "abcd");
assert.equal(cleanText("😀😀😀😀😀", 3), "😀😀…");
assert.equal(Array.from(cleanText("x".repeat(1000))).length, 500);
});
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"],
"exclude": ["test/**", "node_modules", "dist"]
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022"
],
"types": [
"node"
],
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true
},
"include": [
"src/**/*.ts"
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"noEmit": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}