Заведи каркас пакета и утилитный слой 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:
29 files changed
+11350
No files matched your search
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tgz
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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" } },
|
||||
},
|
||||
);
|
||||
Generated
+3302
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }] };
|
||||
}
|
||||
Vendored
+6435
File diff suppressed because it is too large.
Load diff
+117
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()}…`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"balance":"1234.56789012","referral_balance":"0"}
|
||||
Vendored
+43
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+254
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1
@@ -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"}
|
||||
Vendored
+72
@@ -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 (последняя версия)"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+1
@@ -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"}}
|
||||
Vendored
+436
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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}`))));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["test/**", "node_modules", "dist"]
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user