@router-ai/dsh-plugin-routerai 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,1354 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join } from "node:path";
3
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
4
+ import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
5
+ import { LlmError, assertUsableApiKey, resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
6
+ import z from "@deepseek-ai/schemastery";
7
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
8
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
9
+ import { spawn } from "node:child_process";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { createServer } from "node:http";
12
+ import { InMemoryCredentialStore, createProvider } from "@earendil-works/pi-ai";
13
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
14
+ // src/links.ts
15
+ const API_BASE = "https://routerai.ru/api/v1";
16
+ const KEYS_PAGE = "https://routerai.ru/settings/keys";
17
+ const BILLING_PAGE = "https://routerai.ru/settings/billing";
18
+ /** Идентификатор маршрута в harness: так провайдер называется в пикере и конфигурации. */
19
+ const ROUTE = "routerai";
20
+ // src/failures.ts
21
+ const SIGNS = [
22
+ ["key-off", /api key is disabled/i],
23
+ ["key-cap", /spending limit exceeded/i],
24
+ ["no-funds", /недостаточно средств|insufficient balance/i]
25
+ ];
26
+ function recognize(failure) {
27
+ for (const [kind, pattern] of SIGNS) if (pattern.test(failure.message)) return kind;
28
+ return failure.status === 402 ? "no-funds" : void 0;
29
+ }
30
+ function explain(kind, original) {
31
+ switch (kind) {
32
+ case "no-funds": return `На балансе RouterAI закончились деньги. Пополнить счёт: ${BILLING_PAGE}`;
33
+ case "key-off": return `Этот ключ RouterAI отключён. Включить его можно на ${KEYS_PAGE}, получить новый — командой /routerai login`;
34
+ case "key-cap": {
35
+ const numbers = /exceeded:\s*([\d.]+)\s*\/\s*([\d.]+)/i.exec(original);
36
+ return `Ключ RouterAI упёрся в свой лимит расходов${numbers === null ? "" : ` (потрачено ${numbers[1]} ₽ из ${numbers[2]} ₽)`}. Лимит меняется на ${KEYS_PAGE}; команда /routerai login выдаст ключ с новым лимитом`;
37
+ }
38
+ }
39
+ }
40
+ function describeProblem(failure) {
41
+ const kind = recognize(failure);
42
+ return kind === void 0 ? void 0 : {
43
+ kind,
44
+ text: explain(kind, failure.message),
45
+ at: Date.now()
46
+ };
47
+ }
48
+ // src/adapter.ts
49
+ var RouterAiAdapter = class extends PiAiAdapter {
50
+ observer;
51
+ constructor(options, observer) {
52
+ super(options);
53
+ this.observer = observer;
54
+ }
55
+ async listModels(provider) {
56
+ const listed = [];
57
+ for (const model of await super.listModels(provider)) {
58
+ if (!this.observer.offered(model.id)) continue;
59
+ const caption = this.observer.caption(model.id);
60
+ listed.push(caption === "" ? model : {
61
+ ...model,
62
+ description: caption
63
+ });
64
+ }
65
+ return listed;
66
+ }
67
+ stream(options) {
68
+ return this.watch(options, super.stream(options));
69
+ }
70
+ /** Агент «замораживает» вызов на шаг через prepareCall — наблюдать нужно и этот путь. */
71
+ async prepareCall(provider, model, signal) {
72
+ const call = await super.prepareCall(provider, model, signal);
73
+ return {
74
+ ...call,
75
+ stream: (options) => this.watch(options, call.stream(options))
76
+ };
77
+ }
78
+ async *watch(options, source) {
79
+ try {
80
+ for await (const chunk of source) {
81
+ if (chunk.type === "usage") this.observer.spent(options, chunk.usage);
82
+ else if (chunk.type === "finish" && chunk.reason.kind === "error") {
83
+ const problem = describeProblem(chunk.reason.failure);
84
+ if (problem !== void 0) {
85
+ this.observer.refused(problem);
86
+ yield {
87
+ ...chunk,
88
+ reason: {
89
+ kind: "error",
90
+ failure: {
91
+ ...chunk.reason.failure,
92
+ code: "QUOTA",
93
+ message: problem.text
94
+ }
95
+ }
96
+ };
97
+ continue;
98
+ }
99
+ }
100
+ yield chunk;
101
+ }
102
+ } catch (error) {
103
+ const problem = error instanceof LlmError ? describeProblem({
104
+ message: error.message,
105
+ status: error.status
106
+ }) : void 0;
107
+ if (problem === void 0) throw error;
108
+ this.observer.refused(problem);
109
+ throw new LlmError(problem.text, "QUOTA", { cause: error });
110
+ }
111
+ }
112
+ };
113
+ // src/account.ts
114
+ /** Ключи RouterAI начинаются с `sk-`, дальше — urlsafe-base64. */
115
+ function looksLikeApiKey(text) {
116
+ return /^sk-[\w-]{16,200}$/.test(text);
117
+ }
118
+ /** Ключ для показа: начало и конец, середина скрыта. */
119
+ function maskApiKey(key) {
120
+ return key.length < 14 ? "sk-…" : `${key.slice(0, 7)}…${key.slice(-4)}`;
121
+ }
122
+ function number(value) {
123
+ const parsed = typeof value === "string" ? Number(value) : value;
124
+ return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : 0;
125
+ }
126
+ function keyFacts(data) {
127
+ const facts = {
128
+ label: typeof data["name"] === "string" ? data["name"] : "",
129
+ disabled: data["disabled"] === true,
130
+ spent: number(data["usage"]),
131
+ spentThisMonth: number(data["usage_monthly"])
132
+ };
133
+ const amount = number(data["limit"]);
134
+ if (amount > 0) {
135
+ const period = data["limit_reset"];
136
+ facts.cap = {
137
+ amount,
138
+ left: Math.max(0, number(data["limit_remaining"])),
139
+ period: period === "daily" || period === "weekly" || period === "monthly" ? period : "other"
140
+ };
141
+ }
142
+ return facts;
143
+ }
144
+ async function ask(apiBase, path, apiKey) {
145
+ try {
146
+ const response = await fetch(`${apiBase}${path}`, {
147
+ headers: {
148
+ authorization: `Bearer ${apiKey}`,
149
+ accept: "application/json"
150
+ },
151
+ signal: AbortSignal.timeout(15e3)
152
+ });
153
+ if (!response.ok) return {
154
+ ok: false,
155
+ status: response.status,
156
+ note: `${path} → HTTP ${response.status}`
157
+ };
158
+ const body = await response.json();
159
+ return {
160
+ ok: true,
161
+ data: typeof body.data === "object" && body.data !== null ? body.data : {}
162
+ };
163
+ } catch (error) {
164
+ return {
165
+ ok: false,
166
+ status: void 0,
167
+ note: `${path} → ${error instanceof Error ? error.message : String(error)}`
168
+ };
169
+ }
170
+ }
171
+ async function loadAccount(apiBase, apiKey) {
172
+ const [credits, key] = await Promise.all([ask(apiBase, "/credits", apiKey), ask(apiBase, "/key", apiKey)]);
173
+ const view = {
174
+ state: "ok",
175
+ notes: []
176
+ };
177
+ if (credits.ok) view.balance = number(credits.data["credits"]);
178
+ if (key.ok) view.key = keyFacts(key.data);
179
+ const failed = [credits, key].filter((reply) => !reply.ok);
180
+ view.notes = failed.map((reply) => reply.note);
181
+ if (failed.some((reply) => reply.status === 401 || reply.status === 403)) view.state = "rejected";
182
+ else if (failed.length === 2) view.state = "offline";
183
+ else if (failed.length === 1) view.state = "partial";
184
+ return view;
185
+ }
186
+ // src/storage.ts
187
+ /** Коды, с которыми Windows отказывает в подмене файла, пока его держит другой процесс. */
188
+ const BUSY_CODES = [
189
+ "EBUSY",
190
+ "EPERM",
191
+ "EACCES"
192
+ ];
193
+ const RENAME_ATTEMPTS = 10;
194
+ let sequence = 0;
195
+ async function swapIn(temporary, target) {
196
+ for (let attempt = 1;; attempt += 1) try {
197
+ await rename(temporary, target);
198
+ return;
199
+ } catch (error) {
200
+ const code = error.code;
201
+ if (!(process.platform === "win32" && code !== void 0 && BUSY_CODES.includes(code)) || attempt === RENAME_ATTEMPTS) throw error;
202
+ await new Promise((resolve) => setTimeout(resolve, 40 * attempt));
203
+ }
204
+ }
205
+ var JsonFile = class {
206
+ path;
207
+ accept;
208
+ /** Записи одного файла выстраиваются в очередь: последняя запись побеждает предсказуемо. */
209
+ queue = Promise.resolve();
210
+ /**
211
+ * @param path - путь к файлу.
212
+ * @param accept - проверка прочитанного: возвращает значение или undefined, если документ не годится.
213
+ */
214
+ constructor(path, accept) {
215
+ this.path = path;
216
+ this.accept = accept;
217
+ }
218
+ /** Прочитать документ; undefined, если файла нет, он повреждён или не прошёл проверку. */
219
+ async read() {
220
+ let text;
221
+ try {
222
+ text = await readFile(this.path, "utf8");
223
+ } catch {
224
+ return;
225
+ }
226
+ try {
227
+ return this.accept(JSON.parse(text));
228
+ } catch {
229
+ return;
230
+ }
231
+ }
232
+ write(value) {
233
+ const body = `${JSON.stringify(value, null, 1)}\n`;
234
+ const job = this.queue.then(() => this.replace(body));
235
+ this.queue = job.catch(() => {});
236
+ return job;
237
+ }
238
+ async replace(body) {
239
+ await mkdir(dirname(this.path), { recursive: true });
240
+ sequence += 1;
241
+ const temporary = `${this.path}.${process.pid}-${sequence}.part`;
242
+ try {
243
+ await writeFile(temporary, body, "utf8");
244
+ await swapIn(temporary, this.path);
245
+ } catch (error) {
246
+ await unlink(temporary).catch(() => {});
247
+ throw error;
248
+ }
249
+ }
250
+ };
251
+ // src/costs.ts
252
+ /** Стоимость одного вызова по цене модели; undefined, если цены нет. */
253
+ function priceCall(card, tokens) {
254
+ const price = card?.price;
255
+ if (price === void 0) return void 0;
256
+ return ((tokens.inputTokens ?? 0) * price.input + (tokens.cacheReadTokens ?? 0) * (price.cachedInput ?? price.input) + (tokens.cacheWriteTokens ?? 0) * (price.cacheWrite ?? price.input) + (tokens.outputTokens ?? 0) * price.output) / 1e6;
257
+ }
258
+ /** Какая часть входа пришла из кэша; undefined, пока входа не было. */
259
+ function cachedShare(tokens) {
260
+ const input = tokens.input + tokens.cached + tokens.cacheWrites;
261
+ return input > 0 ? tokens.cached / input : void 0;
262
+ }
263
+ const KEEP_SESSIONS = 300;
264
+ const FLUSH_AFTER_MS = 1500;
265
+ function blank() {
266
+ return {
267
+ total: 0,
268
+ calls: 0,
269
+ unpriced: 0,
270
+ last: null,
271
+ tokens: {
272
+ input: 0,
273
+ cached: 0,
274
+ cacheWrites: 0,
275
+ output: 0
276
+ },
277
+ perModel: {},
278
+ touchedAt: 0
279
+ };
280
+ }
281
+ var CostBook = class {
282
+ onWriteError;
283
+ sessions = /* @__PURE__ */ new Map();
284
+ flushTimer;
285
+ file;
286
+ constructor(path, onWriteError = () => {}) {
287
+ this.onWriteError = onWriteError;
288
+ this.file = new JsonFile(path, (raw) => typeof raw === "object" && raw !== null ? raw : void 0);
289
+ }
290
+ async restore() {
291
+ const saved = await this.file.read();
292
+ if (saved !== void 0) this.sessions = new Map(Object.entries(saved));
293
+ }
294
+ of(sessionId) {
295
+ return this.sessions.get(sessionId);
296
+ }
297
+ /** Учесть вызов чат-модели. */
298
+ addCall(sessionId, model, card, tokens) {
299
+ const rub = priceCall(card, tokens);
300
+ const entry = this.open(sessionId);
301
+ entry.calls += 1;
302
+ if (rub === void 0) entry.unpriced += 1;
303
+ entry.total += rub ?? 0;
304
+ entry.last = {
305
+ model,
306
+ rub: rub ?? 0
307
+ };
308
+ entry.tokens.input += tokens.inputTokens ?? 0;
309
+ entry.tokens.cached += tokens.cacheReadTokens ?? 0;
310
+ entry.tokens.cacheWrites += tokens.cacheWriteTokens ?? 0;
311
+ entry.tokens.output += tokens.outputTokens ?? 0;
312
+ const share = entry.perModel[model] ?? {
313
+ calls: 0,
314
+ rub: 0
315
+ };
316
+ entry.perModel[model] = {
317
+ calls: share.calls + 1,
318
+ rub: share.rub + (rub ?? 0)
319
+ };
320
+ this.close(sessionId, entry);
321
+ }
322
+ open(sessionId) {
323
+ const existing = this.sessions.get(sessionId);
324
+ return existing === void 0 ? blank() : structuredClone(existing);
325
+ }
326
+ close(sessionId, entry) {
327
+ entry.touchedAt = Date.now();
328
+ this.sessions.delete(sessionId);
329
+ this.sessions.set(sessionId, entry);
330
+ for (const stale of this.sessions.keys()) {
331
+ if (this.sessions.size <= KEEP_SESSIONS) break;
332
+ this.sessions.delete(stale);
333
+ }
334
+ clearTimeout(this.flushTimer);
335
+ this.flushTimer = setTimeout(() => {
336
+ this.flush();
337
+ }, FLUSH_AFTER_MS);
338
+ }
339
+ async flush() {
340
+ clearTimeout(this.flushTimer);
341
+ try {
342
+ await this.file.write(Object.fromEntries(this.sessions));
343
+ } catch (error) {
344
+ this.onWriteError(error);
345
+ }
346
+ }
347
+ };
348
+ // src/signin.ts
349
+ /**
350
+ * Под этим именем приложение видно пользователю на странице согласия. RouterAI привязывает
351
+ * выданный ключ к имени приложения и при повторном входе перевыпускает его, а не создаёт новый,
352
+ * поэтому имя должно оставаться постоянным.
353
+ */
354
+ const CLIENT_NAME = "DeepSeek Harness";
355
+ const WAIT_LIMIT_MS = 9e5;
356
+ const RETURN_PATH = "/routerai/return";
357
+ const urlSafe = (bytes) => bytes.toString("base64url");
358
+ function newChallenge() {
359
+ const secret = urlSafe(randomBytes(40));
360
+ return {
361
+ secret,
362
+ digest: urlSafe(createHash("sha256").update(secret).digest()),
363
+ ticket: urlSafe(randomBytes(18))
364
+ };
365
+ }
366
+ /** Адрес страницы согласия. Она живёт на сайте, а не под /api. */
367
+ function consentUrl(apiBase, challenge, returnTo) {
368
+ const page = new URL("/auth", apiBase);
369
+ page.search = new URLSearchParams({
370
+ app_name: CLIENT_NAME,
371
+ key_label: CLIENT_NAME,
372
+ callback_url: returnTo,
373
+ code_challenge: challenge.digest,
374
+ code_challenge_method: "S256",
375
+ state: challenge.ticket
376
+ }).toString();
377
+ return page.href;
378
+ }
379
+ async function redeemCode(apiBase, code, challenge, returnTo) {
380
+ const response = await fetch(`${apiBase}/auth/keys`, {
381
+ method: "POST",
382
+ headers: {
383
+ "content-type": "application/json",
384
+ accept: "application/json"
385
+ },
386
+ body: JSON.stringify({
387
+ code,
388
+ code_verifier: challenge.secret,
389
+ code_challenge_method: "S256",
390
+ callback_url: returnTo
391
+ }),
392
+ signal: AbortSignal.timeout(2e4)
393
+ });
394
+ const reply = await response.json().catch(() => ({}));
395
+ if (response.ok && typeof reply["key"] === "string" && reply["key"] !== "") return reply["key"];
396
+ const reason = reply["error_description"] ?? reply["error"] ?? `HTTP ${response.status}`;
397
+ throw new Error(`RouterAI не выдал ключ: ${String(reason)}`);
398
+ }
399
+ function answer(response, status, headline, detail) {
400
+ const safe = (text) => text.replace(/[<>&"]/g, (symbol) => `&#${symbol.charCodeAt(0)};`);
401
+ response.writeHead(status, {
402
+ "content-type": "text/html; charset=utf-8",
403
+ "cache-control": "no-store"
404
+ });
405
+ response.end(`<!doctype html><html lang="ru"><meta charset="utf-8"><title>RouterAI</title>
406
+ <body style="margin:0;min-height:100vh;display:grid;place-items:center;font-family:system-ui,sans-serif;background:#f5f7fb;color:#1b2333">
407
+ <main style="max-width:28rem;padding:2rem;text-align:center"><h1 style="font-size:1.4rem">${safe(headline)}</h1><p>${safe(detail)}</p></main></body></html>`);
408
+ }
409
+ /** Начать вход: поднять локальный приёмник ответа и вернуть адрес страницы согласия. */
410
+ async function startSignIn(apiBase) {
411
+ const challenge = newChallenge();
412
+ let returnTo = "";
413
+ let finish = () => {};
414
+ const key = new Promise((resolve, reject) => {
415
+ finish = (outcome) => "key" in outcome ? resolve(outcome.key) : reject(outcome.error);
416
+ });
417
+ key.catch(() => {});
418
+ const handle = async (request, response) => {
419
+ const target = new URL(request.url ?? "/", returnTo);
420
+ if (target.pathname !== RETURN_PATH) {
421
+ answer(response, 404, "Страница не найдена", "Этот адрес служит только для возврата со страницы входа RouterAI.");
422
+ return;
423
+ }
424
+ try {
425
+ if (target.searchParams.get("state") !== challenge.ticket) throw new Error("Ответ не относится к начатому входу. Запустите вход ещё раз.");
426
+ if (target.searchParams.get("error") === "access_denied") throw new Error("Вы отклонили доступ на странице RouterAI.");
427
+ const code = target.searchParams.get("code");
428
+ if (code === null || code === "") throw new Error(`RouterAI вернул ошибку: ${target.searchParams.get("error") ?? "нет кода авторизации"}`);
429
+ const issued = await redeemCode(apiBase, code, challenge, returnTo);
430
+ answer(response, 200, "Готово", "Ключ RouterAI сохранён в DeepSeek Harness. Эту вкладку можно закрыть.");
431
+ finish({ key: issued });
432
+ } catch (error) {
433
+ const problem = error instanceof Error ? error : new Error(String(error));
434
+ answer(response, 400, "Войти не получилось", problem.message);
435
+ finish({ error: problem });
436
+ }
437
+ };
438
+ const server = createServer((request, response) => {
439
+ handle(request, response);
440
+ });
441
+ await new Promise((resolve, reject) => {
442
+ server.once("error", reject);
443
+ server.listen(0, "127.0.0.1", resolve);
444
+ });
445
+ returnTo = `http://127.0.0.1:${server.address().port}${RETURN_PATH}`;
446
+ const timeout = setTimeout(() => {
447
+ finish({ error: /* @__PURE__ */ new Error("Вход не был подтверждён за 15 минут.") });
448
+ }, WAIT_LIMIT_MS);
449
+ const shutdown = () => {
450
+ clearTimeout(timeout);
451
+ server.close();
452
+ server.closeAllConnections();
453
+ };
454
+ key.then(shutdown, shutdown);
455
+ return {
456
+ url: consentUrl(apiBase, challenge, returnTo),
457
+ key,
458
+ cancel: () => {
459
+ finish({ error: /* @__PURE__ */ new Error("Вход отменён.") });
460
+ }
461
+ };
462
+ }
463
+ /** Как на этой системе открыть адрес в браузере по умолчанию. */
464
+ function browserLauncher(url, platform = process.platform) {
465
+ if (platform === "win32") return {
466
+ program: "explorer.exe",
467
+ args: [url]
468
+ };
469
+ return {
470
+ program: platform === "darwin" ? "open" : "xdg-open",
471
+ args: [url]
472
+ };
473
+ }
474
+ /** Попытаться открыть браузер. Не получилось — не беда: адрес показан пользователю. */
475
+ function openBrowser(url) {
476
+ const { program, args } = browserLauncher(url);
477
+ try {
478
+ const child = spawn(program, args, {
479
+ detached: true,
480
+ stdio: "ignore",
481
+ windowsHide: true
482
+ });
483
+ child.once("error", () => {});
484
+ child.unref();
485
+ } catch {}
486
+ }
487
+ // src/bridge.ts
488
+ const BRIDGE_ROOT = "/api/routerai";
489
+ const ACCOUNT_FRESH_MS = 3e4;
490
+ const PROBLEM_SHOWN_MS = 12e5;
491
+ const BODY_LIMIT = 1e5;
492
+ var Bridge = class {
493
+ deps;
494
+ signIn = { stage: "idle" };
495
+ pending;
496
+ accountCache;
497
+ problem = null;
498
+ constructor(deps) {
499
+ this.deps = deps;
500
+ }
501
+ /** RouterAI отказал из-за денег — интерфейс покажет предупреждение. */
502
+ noteProblem(problem) {
503
+ this.problem = problem;
504
+ this.accountCache = void 0;
505
+ }
506
+ /** Вызов прошёл — предупреждение о деньгах больше не актуально. */
507
+ noteSuccess() {
508
+ this.problem = null;
509
+ }
510
+ async overview(fresh) {
511
+ const key = await this.deps.readKey();
512
+ const preferences = this.deps.preferences;
513
+ return {
514
+ connected: key !== void 0,
515
+ keyMask: key === void 0 ? null : maskApiKey(key),
516
+ signIn: this.signIn,
517
+ account: key === void 0 ? null : await this.account(key, fresh),
518
+ picker: {
519
+ models: [...preferences.pickerModels()],
520
+ custom: preferences.customized
521
+ },
522
+ utilityModel: preferences.current.utilityModel,
523
+ lowBalance: preferences.current.lowBalance,
524
+ problem: this.problem !== null && Date.now() - this.problem.at < PROBLEM_SHOWN_MS ? this.problem : null
525
+ };
526
+ }
527
+ async account(key, fresh) {
528
+ const cached = this.accountCache;
529
+ if (!fresh && cached !== void 0 && cached.forKey === key && Date.now() - cached.at < ACCOUNT_FRESH_MS) return cached.view;
530
+ const view = await loadAccount(this.deps.apiBase, key);
531
+ this.accountCache = {
532
+ forKey: key,
533
+ at: Date.now(),
534
+ view
535
+ };
536
+ return view;
537
+ }
538
+ async beginSignIn() {
539
+ this.pending?.cancel();
540
+ const attempt = await startSignIn(this.deps.apiBase);
541
+ this.pending = attempt;
542
+ this.signIn = {
543
+ stage: "waiting",
544
+ url: attempt.url
545
+ };
546
+ openBrowser(attempt.url);
547
+ attempt.key.then(async (key) => {
548
+ await this.deps.storeKey(key);
549
+ this.accountCache = void 0;
550
+ this.problem = null;
551
+ if (this.pending === attempt) this.signIn = { stage: "idle" };
552
+ }, (error) => {
553
+ if (this.pending !== attempt) return;
554
+ const reason = error instanceof Error ? error.message : String(error);
555
+ this.signIn = {
556
+ stage: "failed",
557
+ reason
558
+ };
559
+ this.deps.warn(`вход не завершён: ${reason}`);
560
+ }).finally(() => {
561
+ if (this.pending === attempt) this.pending = void 0;
562
+ });
563
+ return { url: attempt.url };
564
+ }
565
+ abandonSignIn() {
566
+ const attempt = this.pending;
567
+ this.pending = void 0;
568
+ this.signIn = { stage: "idle" };
569
+ attempt?.cancel();
570
+ }
571
+ endpoints() {
572
+ const reply = (body, status = 200) => Response.json(body, {
573
+ status,
574
+ headers: { "cache-control": "no-store" }
575
+ });
576
+ const input = async (request) => {
577
+ const text = await request.text();
578
+ if (text.length > BODY_LIMIT) throw new Error("Слишком большой запрос");
579
+ const parsed = text === "" ? {} : JSON.parse(text);
580
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Ожидался JSON-объект");
581
+ return parsed;
582
+ };
583
+ const param = (request, name) => new URL(request.url).searchParams.get(name);
584
+ return Object.entries({
585
+ overview: ["GET", (request) => this.overview(param(request, "fresh") === "1")],
586
+ catalog: ["GET", async () => {
587
+ await this.deps.catalog.sync();
588
+ return { models: this.deps.catalog.all() };
589
+ }],
590
+ "session-cost": ["GET", async (request) => {
591
+ const cost = this.deps.costs.of(param(request, "id") ?? "");
592
+ return { cost: cost === void 0 ? null : {
593
+ ...cost,
594
+ cachedShare: cachedShare(cost.tokens) ?? null
595
+ } };
596
+ }],
597
+ "sign-in": ["POST", () => this.beginSignIn()],
598
+ "sign-in/cancel": ["POST", async () => {
599
+ this.abandonSignIn();
600
+ return {};
601
+ }],
602
+ "sign-out": ["POST", async () => {
603
+ this.abandonSignIn();
604
+ await this.deps.forgetKey();
605
+ this.accountCache = void 0;
606
+ return {};
607
+ }],
608
+ key: ["POST", async (request) => {
609
+ const key = String((await input(request))["key"] ?? "").trim();
610
+ if (!looksLikeApiKey(key)) throw new Error("Это не похоже на ключ RouterAI: он начинается с sk-");
611
+ await this.deps.storeKey(key);
612
+ this.accountCache = void 0;
613
+ this.problem = null;
614
+ return {};
615
+ }],
616
+ preferences: ["POST", async (request) => {
617
+ await this.deps.preferences.change(await input(request));
618
+ return {};
619
+ }]
620
+ }).map(([name, [method, handler]]) => ({
621
+ path: `${BRIDGE_ROOT}/${name}`,
622
+ methods: [method],
623
+ fetch: async (request) => {
624
+ try {
625
+ return reply(await handler(request));
626
+ } catch (error) {
627
+ return reply({ error: error instanceof Error ? error.message : String(error) }, 400);
628
+ }
629
+ }
630
+ }));
631
+ }
632
+ };
633
+ // src/catalog.ts
634
+ const SLUG = /^~?[a-z0-9][\w.-]*\/[a-z0-9][\w.:-]*$/i;
635
+ function isModelSlug(value) {
636
+ return typeof value === "string" && value.length <= 160 && SLUG.test(value);
637
+ }
638
+ function bag(value) {
639
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
640
+ }
641
+ function strings(value) {
642
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
643
+ }
644
+ function amount(value) {
645
+ const parsed = typeof value === "string" ? Number(value) : value;
646
+ return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
647
+ }
648
+ /** Каталог отдаёт цену за единицу; в «рубли за миллион» переводим только то, что тарифицируется по токенам. */
649
+ function tokenPrice(pricing, units) {
650
+ const perMillion = (field) => {
651
+ const unit = units[field];
652
+ if (unit !== void 0 && unit !== "token") return void 0;
653
+ const value = amount(pricing[field]);
654
+ return value === void 0 ? void 0 : value * 1e6;
655
+ };
656
+ const input = perMillion("prompt");
657
+ const output = perMillion("completion");
658
+ if (input === void 0 || output === void 0) return void 0;
659
+ const price = {
660
+ input,
661
+ output
662
+ };
663
+ const cachedInput = perMillion("input_cache_read");
664
+ const cacheWrite = perMillion("input_cache_write");
665
+ if (cachedInput !== void 0) price.cachedInput = cachedInput;
666
+ if (cacheWrite !== void 0) price.cacheWrite = cacheWrite;
667
+ return price;
668
+ }
669
+ /** «Anthropic: Claude Sonnet 5.5» → производитель и название по отдельности. */
670
+ function splitName(name, slug) {
671
+ const colon = name.indexOf(": ");
672
+ if (colon > 0 && colon <= 40) return {
673
+ maker: name.slice(0, colon),
674
+ title: name.slice(colon + 2).trim() || slug
675
+ };
676
+ const vendor = slug.replace(/^~/, "").split("/")[0] ?? "";
677
+ return {
678
+ maker: vendor.charAt(0).toUpperCase() + vendor.slice(1),
679
+ title: name.trim() || slug
680
+ };
681
+ }
682
+ /** Одна запись ответа; undefined для повреждённых записей и моделей без текстового вывода. */
683
+ function readModelCard(entry) {
684
+ const raw = bag(entry);
685
+ if (!isModelSlug(raw["id"])) return void 0;
686
+ const architecture = bag(raw["architecture"]);
687
+ const produces = strings(architecture["output_modalities"]);
688
+ if (produces.length > 0 && !produces.includes("text")) return void 0;
689
+ const parameters = strings(raw["supported_parameters"]);
690
+ const slug = raw["id"];
691
+ const card = {
692
+ slug,
693
+ ...splitName(typeof raw["name"] === "string" ? raw["name"] : "", slug),
694
+ alias: slug.startsWith("~"),
695
+ tools: parameters.includes("tools"),
696
+ reasoning: parameters.includes("reasoning") || parameters.includes("reasoning_effort"),
697
+ vision: strings(architecture["input_modalities"]).includes("image")
698
+ };
699
+ const window = amount(raw["context_length"]);
700
+ if (window !== void 0 && window > 0) card.window = window;
701
+ const price = tokenPrice(bag(raw["pricing"]), bag(raw["pricing_units"]));
702
+ if (price !== void 0) card.price = price;
703
+ return card;
704
+ }
705
+ /** Ответ `GET /models` целиком. */
706
+ function readModelList(payload) {
707
+ const entries = bag(payload)["data"];
708
+ if (!Array.isArray(entries)) return [];
709
+ const cards = [];
710
+ for (const entry of entries) {
711
+ const card = readModelCard(entry);
712
+ if (card !== void 0) cards.push(card);
713
+ }
714
+ return cards;
715
+ }
716
+ async function downloadModelList(apiBase, signal) {
717
+ const deadline = AbortSignal.timeout(6e4);
718
+ const response = await fetch(`${apiBase}/models`, {
719
+ headers: { accept: "application/json" },
720
+ signal: signal === void 0 ? deadline : AbortSignal.any([signal, deadline])
721
+ });
722
+ if (!response.ok) throw new Error(`каталог RouterAI недоступен: HTTP ${response.status}`);
723
+ const cards = readModelList(await response.json());
724
+ if (cards.length === 0) throw new Error("каталог RouterAI вернул пустой список моделей");
725
+ return cards;
726
+ }
727
+ function acceptSnapshot(raw) {
728
+ const value = bag(raw);
729
+ if (value["format"] !== 1 || typeof value["source"] !== "string" || typeof value["savedAt"] !== "number") return void 0;
730
+ if (!Array.isArray(value["cards"])) return void 0;
731
+ const cards = value["cards"].filter((card) => isModelSlug(bag(card)["slug"]));
732
+ return {
733
+ format: 1,
734
+ source: value["source"],
735
+ savedAt: value["savedAt"],
736
+ cards
737
+ };
738
+ }
739
+ /**
740
+ * Копия каталога в памяти. Чтение всегда мгновенное: маршрут собирает список моделей
741
+ * на каждый запрос и ждать сеть не может. Сеть трогает только `sync()`.
742
+ */
743
+ var ModelCatalog = class {
744
+ options;
745
+ index = /* @__PURE__ */ new Map();
746
+ stamp = 0;
747
+ running;
748
+ listeners = /* @__PURE__ */ new Set();
749
+ disk;
750
+ download;
751
+ clock;
752
+ /** Последняя ошибка обновления — для диагностики в интерфейсе и логе. */
753
+ lastError;
754
+ constructor(options) {
755
+ this.options = options;
756
+ this.disk = options.file === void 0 ? void 0 : new JsonFile(options.file, acceptSnapshot);
757
+ this.download = options.download ?? ((apiBase) => downloadModelList(apiBase));
758
+ this.clock = options.clock ?? Date.now;
759
+ }
760
+ get size() {
761
+ return this.index.size;
762
+ }
763
+ all() {
764
+ return [...this.index.values()];
765
+ }
766
+ find(slug) {
767
+ return this.index.get(slug);
768
+ }
769
+ /** Подписка на замену содержимого; возвращает функцию отписки. */
770
+ watch(listener) {
771
+ this.listeners.add(listener);
772
+ return () => {
773
+ this.listeners.delete(listener);
774
+ };
775
+ }
776
+ /** Поднять копию с диска (если она от того же API). */
777
+ async restore() {
778
+ const saved = await this.disk?.read();
779
+ if (saved === void 0 || saved.source !== this.options.apiBase || saved.cards.length === 0) return;
780
+ this.adopt(saved.cards, saved.savedAt);
781
+ }
782
+ /** Обновить из сети, если копия устарела или её нет. Сбой оставляет прежнюю копию. */
783
+ sync(force = false) {
784
+ if (this.index.size > 0 && this.clock() - this.stamp < this.options.maxAgeMs && !force) return Promise.resolve();
785
+ this.running ??= this.pull().finally(() => {
786
+ this.running = void 0;
787
+ });
788
+ return this.running;
789
+ }
790
+ async pull() {
791
+ try {
792
+ const cards = await this.download(this.options.apiBase);
793
+ this.lastError = void 0;
794
+ this.adopt(cards, this.clock());
795
+ await this.disk?.write({
796
+ format: 1,
797
+ source: this.options.apiBase,
798
+ savedAt: this.stamp,
799
+ cards
800
+ });
801
+ } catch (error) {
802
+ this.lastError = error instanceof Error ? error.message : String(error);
803
+ }
804
+ }
805
+ adopt(cards, stamp) {
806
+ this.index = new Map(cards.map((card) => [card.slug, card]));
807
+ this.stamp = stamp;
808
+ for (const listener of this.listeners) listener();
809
+ }
810
+ };
811
+ // src/money.ts
812
+ /** Сколько знаков после запятой показать, чтобы сумма не выглядела нулём и не шумела копейками. */
813
+ function fractionDigits(amount) {
814
+ const size = Math.abs(amount);
815
+ if (size === 0 || size >= 100) return 0;
816
+ if (size >= 1) return 2;
817
+ if (size >= .01) return 3;
818
+ return 4;
819
+ }
820
+ function rub(amount) {
821
+ if (amount === null || amount === void 0 || !Number.isFinite(amount)) return "—";
822
+ const digits = fractionDigits(amount);
823
+ return `${new Intl.NumberFormat("ru-RU", {
824
+ minimumFractionDigits: digits,
825
+ maximumFractionDigits: digits
826
+ }).format(amount)} ₽`;
827
+ }
828
+ /** «216 ₽ / 1 079 ₽» — вход и выход за миллион токенов. */
829
+ function pricePair(price) {
830
+ return price === void 0 ? "" : `${rub(price.input)} / ${rub(price.output)}`;
831
+ }
832
+ /** Размер контекстного окна: 1048576 → «1M», 200000 → «200K». */
833
+ function windowLabel(tokens) {
834
+ if (tokens === void 0) return "";
835
+ if (tokens >= 1e6) return `${Math.round(tokens / 1e5) / 10}M`;
836
+ return `${Math.round(tokens / 1e3)}K`;
837
+ }
838
+ const INITIAL = {
839
+ utilityModel: "deepseek/deepseek-v4.1-flash",
840
+ lowBalance: 100
841
+ };
842
+ /** Привести список моделей к каноническому виду или объяснить, что с ним не так. */
843
+ function cleanModelList(value) {
844
+ if (!Array.isArray(value)) throw new Error("Список моделей должен быть массивом");
845
+ const seen = /* @__PURE__ */ new Set();
846
+ for (const item of value) {
847
+ if (!isModelSlug(item)) throw new Error(`«${String(item)}» не похоже на идентификатор модели вида vendor/model`);
848
+ seen.add(item);
849
+ }
850
+ if (seen.size > 150) throw new Error(`В пикере может быть не больше 150 моделей`);
851
+ return [...seen];
852
+ }
853
+ function fromDisk(raw) {
854
+ if (typeof raw !== "object" || raw === null) return void 0;
855
+ const stored = raw;
856
+ const result = { ...INITIAL };
857
+ try {
858
+ if (stored["models"] !== void 0) result.models = cleanModelList(stored["models"]);
859
+ } catch {}
860
+ if (stored["utilityModel"] === null || isModelSlug(stored["utilityModel"])) result.utilityModel = stored["utilityModel"];
861
+ if (typeof stored["lowBalance"] === "number" && stored["lowBalance"] >= 0) result.lowBalance = stored["lowBalance"];
862
+ return result;
863
+ }
864
+ var PreferenceStore = class {
865
+ configuredModels;
866
+ value = { ...INITIAL };
867
+ file;
868
+ listeners = /* @__PURE__ */ new Set();
869
+ constructor(path, configuredModels) {
870
+ this.configuredModels = configuredModels;
871
+ this.file = new JsonFile(path, fromDisk);
872
+ }
873
+ get current() {
874
+ return this.value;
875
+ }
876
+ /** Модели пикера: выбор пользователя или, если его нет, список из конфигурации. */
877
+ pickerModels() {
878
+ return this.value.models ?? this.configuredModels();
879
+ }
880
+ get customized() {
881
+ return this.value.models !== void 0;
882
+ }
883
+ watch(listener) {
884
+ this.listeners.add(listener);
885
+ return () => {
886
+ this.listeners.delete(listener);
887
+ };
888
+ }
889
+ async restore() {
890
+ const saved = await this.file.read();
891
+ if (saved === void 0) return;
892
+ this.value = saved;
893
+ this.notify();
894
+ }
895
+ /** Применить изменения из интерфейса; неверные значения отклоняются с понятной ошибкой. */
896
+ async change(patch) {
897
+ const next = { ...this.value };
898
+ if ("models" in patch) next.models = cleanModelList(patch["models"]);
899
+ if ("utilityModel" in patch) {
900
+ const model = patch["utilityModel"];
901
+ if (model !== null && !isModelSlug(model)) throw new Error("Служебная модель: ожидался идентификатор vendor/model или null");
902
+ next.utilityModel = model;
903
+ }
904
+ if ("lowBalance" in patch) {
905
+ const threshold = patch["lowBalance"];
906
+ if (typeof threshold !== "number" || !Number.isFinite(threshold) || threshold < 0) throw new Error("Порог баланса: ожидалось неотрицательное число");
907
+ next.lowBalance = threshold;
908
+ }
909
+ await this.file.write(next);
910
+ this.value = next;
911
+ this.notify();
912
+ return next;
913
+ }
914
+ notify() {
915
+ for (const listener of this.listeners) listener();
916
+ }
917
+ };
918
+ // src/provider.ts
919
+ const PROVIDER_NAME = "RouterAI";
920
+ /** Каталог не сообщает предел вывода; этого хватает на длинные правки и не упирается в лимиты апстримов. */
921
+ const OUTPUT_CEILING = 32768;
922
+ /** Окно для модели, о которой каталог ещё ничего не сказал. */
923
+ const UNKNOWN_WINDOW = 128e3;
924
+ function wireDialect(slug) {
925
+ const dialect = {
926
+ thinkingFormat: "openrouter",
927
+ supportsReasoningEffort: true,
928
+ supportsUsageInStreaming: true,
929
+ maxTokensField: "max_tokens",
930
+ supportsStore: false,
931
+ supportsDeveloperRole: false
932
+ };
933
+ if (/^~?anthropic\//.test(slug)) dialect.cacheControlFormat = "anthropic";
934
+ return dialect;
935
+ }
936
+ /** RouterAI принимает усилия low/medium/high; прочие уровни harness модели не предлагает. */
937
+ function thinkingLevels() {
938
+ return {
939
+ off: null,
940
+ minimal: null,
941
+ low: "low",
942
+ medium: "medium",
943
+ high: "high",
944
+ xhigh: null,
945
+ max: null
946
+ };
947
+ }
948
+ function describeModel(card, apiBase) {
949
+ const window = card.window ?? UNKNOWN_WINDOW;
950
+ return {
951
+ id: card.slug,
952
+ name: card.title,
953
+ provider: ROUTE,
954
+ api: "openai-completions",
955
+ baseUrl: apiBase,
956
+ input: card.vision ? ["text", "image"] : ["text"],
957
+ contextWindow: window,
958
+ maxTokens: Math.min(OUTPUT_CEILING, window),
959
+ reasoning: card.reasoning,
960
+ cost: {
961
+ input: 0,
962
+ output: 0,
963
+ cacheRead: 0,
964
+ cacheWrite: 0
965
+ },
966
+ compat: wireDialect(card.slug),
967
+ ...card.reasoning ? { thinkingLevelMap: thinkingLevels() } : {}
968
+ };
969
+ }
970
+ /** Модель из списка пользователя, пока каталог не загрузился: консервативные возможности. */
971
+ function describeUnknownModel(slug, apiBase) {
972
+ return describeModel({
973
+ slug,
974
+ title: slug,
975
+ maker: "",
976
+ alias: slug.startsWith("~"),
977
+ tools: true,
978
+ reasoning: false,
979
+ vision: false
980
+ }, apiBase);
981
+ }
982
+ /**
983
+ * pi-ai умеет сам искать ключи в окружении и на диске. Этому маршруту это не нужно и вредно:
984
+ * ключ всегда выдаёт harness, поэтому pi-ai получает пустое хранилище и «слепой» контекст.
985
+ */
986
+ const isolatedAuth = {
987
+ credentials: new InMemoryCredentialStore(),
988
+ authContext: {
989
+ env: async () => void 0,
990
+ fileExists: async () => false
991
+ }
992
+ };
993
+ function buildRoute(models, apiBase, credential) {
994
+ const provider = createProvider({
995
+ id: ROUTE,
996
+ name: PROVIDER_NAME,
997
+ baseUrl: apiBase,
998
+ models: [...models],
999
+ api: openAICompletionsApi(),
1000
+ auth: { apiKey: {
1001
+ name: PROVIDER_NAME,
1002
+ resolve: async ({ credential: given }) => ({
1003
+ auth: given?.key === void 0 ? {} : { apiKey: given.key },
1004
+ source: PROVIDER_NAME
1005
+ })
1006
+ } }
1007
+ });
1008
+ return {
1009
+ provider: ROUTE,
1010
+ displayName: PROVIDER_NAME,
1011
+ apiKeyEnv: credential,
1012
+ piProvider: provider,
1013
+ retryPolicy: resolveRetryPolicy(void 0, "llm-routerai.retryPolicy"),
1014
+ streamIdleTimeoutMs: 3e5,
1015
+ maxRequestImages: 50,
1016
+ maxRequestImageBytes: 20971520,
1017
+ requestImageMaxBytes: 1048576,
1018
+ requestImagePixelBudget: 4194304,
1019
+ imageOffloadCountQuantum: 10,
1020
+ modelErrors: /* @__PURE__ */ new Map(),
1021
+ configuredMaxTokens: /* @__PURE__ */ new Map()
1022
+ };
1023
+ }
1024
+ // src/slash.ts
1025
+ const USAGE = `Команда /routerai:
1026
+ login войти через routerai.ru — ключ выдаст сайт
1027
+ key sk-… сохранить ключ, созданный вручную
1028
+ logout удалить сохранённый ключ
1029
+ status баланс аккаунта и состояние ключа
1030
+ models модели, которые показывает пикер
1031
+ find <текст> поиск по каталогу RouterAI`;
1032
+ const PERIODS = {
1033
+ daily: "в сутки",
1034
+ weekly: "в неделю",
1035
+ monthly: "в месяц",
1036
+ other: ""
1037
+ };
1038
+ /** Состояние аккаунта текстом — для чата. */
1039
+ function accountReport(account) {
1040
+ if (account.state === "rejected") return "RouterAI не принимает этот ключ: его отозвали или удалили. Получите новый командой /routerai login.";
1041
+ if (account.state === "offline") return `Не удалось связаться с RouterAI (${account.notes.join("; ")}).`;
1042
+ const lines = [];
1043
+ if (account.balance !== void 0) lines.push(`На счёте ${rub(account.balance)}.`);
1044
+ const key = account.key;
1045
+ if (key !== void 0) {
1046
+ const name = key.label === "" ? "Ключ" : `Ключ «${key.label}»`;
1047
+ if (key.disabled) lines.push(`${name} отключён — включить можно на ${KEYS_PAGE}.`);
1048
+ lines.push(key.cap === void 0 ? `${name} без лимита расходов.` : `${name}: лимит ${rub(key.cap.amount)} ${PERIODS[key.cap.period]}`.trimEnd() + `, осталось ${rub(key.cap.left)}.`);
1049
+ lines.push(`Потрачено по ключу: ${rub(key.spent)}, за месяц ${rub(key.spentThisMonth)}.`);
1050
+ }
1051
+ lines.push(...account.notes.map((note) => `Часть данных недоступна: ${note}`));
1052
+ lines.push(`Пополнение: ${BILLING_PAGE}`);
1053
+ return lines.join("\n");
1054
+ }
1055
+ function modelLine(card) {
1056
+ return [
1057
+ card.slug,
1058
+ pricePair(card.price) && `${pricePair(card.price)} за 1М токенов`,
1059
+ windowLabel(card.window),
1060
+ card.tools ? "" : "без инструментов"
1061
+ ].filter((fact) => fact !== "").join(" · ");
1062
+ }
1063
+ function registerSlashCommand(ctx, deps) {
1064
+ const good = (text) => ({
1065
+ kind: "success",
1066
+ text
1067
+ });
1068
+ const bad = (text) => ({
1069
+ kind: "error",
1070
+ text
1071
+ });
1072
+ async function run(verb, argument, signal) {
1073
+ switch (verb) {
1074
+ case "login": {
1075
+ const attempt = await startSignIn(deps.apiBase);
1076
+ signal.addEventListener("abort", attempt.cancel, { once: true });
1077
+ openBrowser(attempt.url);
1078
+ ctx.logger.info(`[routerai] страница входа: ${attempt.url}`);
1079
+ const key = await attempt.key;
1080
+ await deps.storeKey(key);
1081
+ return good(`Вы вошли в RouterAI, ключ сохранён как ${deps.credentialName}.\n${accountReport(await loadAccount(deps.apiBase, key))}`);
1082
+ }
1083
+ case "key":
1084
+ if (!looksLikeApiKey(argument)) return bad(`Нужен ключ RouterAI вида sk-… Создать его можно на ${KEYS_PAGE}`);
1085
+ await deps.storeKey(argument);
1086
+ return good(`Ключ сохранён как ${deps.credentialName}.`);
1087
+ case "logout":
1088
+ await deps.forgetKey();
1089
+ return good(`Ключ ${deps.credentialName} удалён из DeepSeek Harness. Если он задан переменной окружения или в .env, уберите его и оттуда.`);
1090
+ case "status": {
1091
+ const key = await deps.readKey();
1092
+ if (key === void 0) return bad("Ключа RouterAI пока нет. Войдите: /routerai login");
1093
+ return good(accountReport(await loadAccount(deps.apiBase, key)));
1094
+ }
1095
+ case "models": {
1096
+ await deps.catalog.sync();
1097
+ const lines = deps.pickerModels().map((slug) => {
1098
+ const card = deps.catalog.find(slug);
1099
+ return card === void 0 ? `${slug} · в каталоге не найдена` : modelLine(card);
1100
+ });
1101
+ return good(`${lines.join("\n")}\n\nСписок меняется в Настройках → RouterAI.`);
1102
+ }
1103
+ case "find": {
1104
+ await deps.catalog.sync();
1105
+ const needle = argument.toLowerCase();
1106
+ const hits = deps.catalog.all().filter((card) => `${card.slug} ${card.maker} ${card.title}`.toLowerCase().includes(needle));
1107
+ if (hits.length === 0) return bad(`По запросу «${argument}» в каталоге RouterAI ничего нет.`);
1108
+ const shown = hits.slice(0, 30).map(modelLine);
1109
+ if (hits.length > shown.length) shown.push(`…и ещё ${hits.length - shown.length}. Уточните запрос.`);
1110
+ return good(shown.join("\n"));
1111
+ }
1112
+ default: return good(USAGE);
1113
+ }
1114
+ }
1115
+ ctx.commands.register({
1116
+ name: "routerai",
1117
+ description: "RouterAI: вход, баланс, модели",
1118
+ input: { hint: "login | key sk-… | logout | status | models | find <текст>" },
1119
+ recordInput: false,
1120
+ handler: async ({ rawInput, signal }) => {
1121
+ const text = rawInput.trim();
1122
+ const gap = text.search(/\s/);
1123
+ const verb = (gap === -1 ? text : text.slice(0, gap)).toLowerCase();
1124
+ const argument = gap === -1 ? "" : text.slice(gap).trim();
1125
+ try {
1126
+ return await run(verb, argument, signal);
1127
+ } catch (error) {
1128
+ return bad(error instanceof Error ? error.message : String(error));
1129
+ }
1130
+ }
1131
+ });
1132
+ }
1133
+ /** Что видит в пикере новый пользователь. */
1134
+ const STARTER_MODELS = [
1135
+ {
1136
+ key: "everyday",
1137
+ models: [
1138
+ "anthropic/claude-sonnet-5.5",
1139
+ "deepseek/deepseek-v4.1-flash",
1140
+ "openai/gpt-6.1-sol",
1141
+ "google/gemini-4-argon",
1142
+ "anthropic/claude-opus-5.5",
1143
+ "moonshotai/kimi-k3.1-pro",
1144
+ "z-ai/glm-5.3-flash"
1145
+ ]
1146
+ },
1147
+ {
1148
+ key: "coding",
1149
+ models: [
1150
+ "anthropic/claude-opus-5.5",
1151
+ "google/gemini-4-argon",
1152
+ "z-ai/glm-5.3-flash",
1153
+ "openai/gpt-6.1-sol",
1154
+ "moonshotai/kimi-k3.1-pro"
1155
+ ]
1156
+ },
1157
+ {
1158
+ key: "budget",
1159
+ models: ["deepseek/deepseek-v4.1-flash", "z-ai/glm-5.3-flash"]
1160
+ }
1161
+ ][0].models;
1162
+ // src/index.ts
1163
+ /**
1164
+ * RouterAI — провайдер моделей для DeepSeek Harness.
1165
+ *
1166
+ * Плагин регистрирует маршрут `routerai`: запросы уходят в OpenAI-совместимый API RouterAI
1167
+ * через штатный адаптер pi-ai. Сверх этого плагин следит за каталогом моделей и ценами,
1168
+ * помогает получить ключ через браузер, показывает баланс и считает стоимость сессий.
1169
+ *
1170
+ * Настройка из файла (обычно всё делается в Настройках → RouterAI):
1171
+ *
1172
+ * ```yaml
1173
+ * - id: llm-routerai
1174
+ * config:
1175
+ * models: [anthropic/claude-sonnet-5.5, deepseek/deepseek-v4.1-flash]
1176
+ * ```
1177
+ *
1178
+ * @module @router-ai/dsh-plugin-routerai
1179
+ */
1180
+ const name = "llm-routerai";
1181
+ const inject = ["llm"];
1182
+ const Config = z.object({
1183
+ models: z.array(z.string()).default([...STARTER_MODELS]).volatile().description("Модели RouterAI в пикере, по порядку. Выбор в Настройках → RouterAI имеет приоритет."),
1184
+ apiKeyEnv: z.string().default("ROUTERAI_API_KEY").description("Имя, под которым хранится ключ RouterAI (sk-…): учётные данные harness, переменная окружения или .env."),
1185
+ baseURL: z.string().default(API_BASE).description("Адрес API RouterAI."),
1186
+ catalogRefreshHours: z.number().min(.25).default(4).description("Как часто обновлять каталог моделей и цены, в часах.")
1187
+ });
1188
+ /** Каталог состояния плагина внутри дома harness. */
1189
+ function stateDir() {
1190
+ return join(process.env["DSH_HOME"] ?? join(homedir(), ".dsh"), "routerai");
1191
+ }
1192
+ /** Запросы на заголовок сессии и сжатие контекста: им хватает недорогой модели. */
1193
+ const UTILITY_PURPOSES = /* @__PURE__ */ new Set(["session-title", "compaction"]);
1194
+ function apply(ctx, config) {
1195
+ const apiBase = config.baseURL.replace(/\/+$/, "");
1196
+ const credential = credentialRef(config.apiKeyEnv);
1197
+ const home = stateDir();
1198
+ const hours = (value) => value * 36e5;
1199
+ const catalog = new ModelCatalog({
1200
+ apiBase,
1201
+ file: join(home, "catalog.json"),
1202
+ maxAgeMs: hours(config.catalogRefreshHours)
1203
+ });
1204
+ const preferences = new PreferenceStore(join(home, "preferences.json"), () => config.models.get() ?? []);
1205
+ const costs = new CostBook(join(home, "costs.json"), (error) => ctx.logger.warn("[routerai] не удалось сохранить расход: %s", String(error)));
1206
+ const optional = {};
1207
+ const adopt = (service) => {
1208
+ ctx.inject([service], (scope) => {
1209
+ scope.effect(() => {
1210
+ optional[service] = scope.get(service);
1211
+ return () => {
1212
+ delete optional[service];
1213
+ };
1214
+ }, `routerai: ${service}`);
1215
+ });
1216
+ };
1217
+ adopt("credentials");
1218
+ adopt("attachments");
1219
+ adopt("fs");
1220
+ let route;
1221
+ const warnedAbout = /* @__PURE__ */ new Set();
1222
+ const routeChanged = () => {
1223
+ route = void 0;
1224
+ ctx.emit("llm/models-updated", ROUTE);
1225
+ };
1226
+ const currentRoute = () => {
1227
+ if (route !== void 0) return route;
1228
+ const wanted = new Set(preferences.pickerModels());
1229
+ const utility = preferences.current.utilityModel;
1230
+ if (utility !== null) wanted.add(utility);
1231
+ const models = [];
1232
+ for (const slug of wanted) {
1233
+ const card = catalog.find(slug);
1234
+ if (card !== void 0) models.push(describeModel(card, apiBase));
1235
+ else if (catalog.size === 0) models.push(describeUnknownModel(slug, apiBase));
1236
+ else if (!warnedAbout.has(slug)) {
1237
+ warnedAbout.add(slug);
1238
+ ctx.logger.warn(`[routerai] модели ${slug} нет в каталоге RouterAI — в пикер она не попадёт`);
1239
+ }
1240
+ }
1241
+ route = /* @__PURE__ */ new Map([[ROUTE, buildRoute(models, apiBase, credential)]]);
1242
+ return route;
1243
+ };
1244
+ const readKey = async () => {
1245
+ const value = (optional.credentials !== void 0 ? await optional.credentials.resolve(credential) : launchEnvironmentOf(ctx).get(credential))?.value;
1246
+ return value === void 0 || value === "" ? void 0 : assertUsableApiKey(value, name, config.apiKeyEnv);
1247
+ };
1248
+ const keyVault = () => {
1249
+ if (optional.credentials === void 0) throw new Error("В этом режиме DeepSeek Harness нет хранилища ключей — задайте ключ переменной окружения.");
1250
+ return optional.credentials;
1251
+ };
1252
+ const storeKey = async (key) => {
1253
+ await keyVault().set(credential, key);
1254
+ ctx.emit("llm/models-updated", ROUTE);
1255
+ };
1256
+ const forgetKey = async () => {
1257
+ await keyVault().unset(credential);
1258
+ ctx.emit("llm/models-updated", ROUTE);
1259
+ };
1260
+ const bridge = new Bridge({
1261
+ apiBase,
1262
+ readKey,
1263
+ storeKey,
1264
+ forgetKey,
1265
+ catalog,
1266
+ preferences,
1267
+ costs,
1268
+ warn: (message) => ctx.logger.warn(`[routerai] ${message}`)
1269
+ });
1270
+ const adapter = new RouterAiAdapter({
1271
+ profiles: currentRoute,
1272
+ auth: isolatedAuth,
1273
+ resolveApiKey: async () => {
1274
+ const key = await readKey();
1275
+ if (key === void 0) throw new LlmError(`Нет ключа RouterAI (${config.apiKeyEnv}). Войдите командой /routerai login или создайте ключ на ${KEYS_PAGE} и сохраните его: /routerai key sk-…`, "MISSING_CREDENTIAL");
1276
+ return key;
1277
+ },
1278
+ prepareHeaders: (_request, headers) => {
1279
+ headers.set("X-Title", "DeepSeek Harness");
1280
+ headers.set("HTTP-Referer", "https://github.com/router-ai/dsh-plugin-routerai");
1281
+ return headers;
1282
+ },
1283
+ resolveAttachments: () => optional.attachments,
1284
+ resolveImageAccess: (store, ref) => resolveImageAttachmentAccess(store, (path) => optional.fs?.processPathFromHostPath(path), ref),
1285
+ onReplayDegrade: ({ model, reason }) => {
1286
+ ctx.logger.warn(`[routerai] ${model}: служебные данные прошлых ответов переданы упрощённо (${reason})`);
1287
+ }
1288
+ }, {
1289
+ caption: (slug) => {
1290
+ const card = catalog.find(slug);
1291
+ if (card === void 0) return "";
1292
+ const price = pricePair(card.price);
1293
+ return [price && `${price} за 1М токенов`, card.window && `контекст ${windowLabel(card.window)}`].filter(Boolean).join(" · ");
1294
+ },
1295
+ offered: (slug) => preferences.pickerModels().includes(slug),
1296
+ spent: (call, tokens) => {
1297
+ bridge.noteSuccess();
1298
+ if (call.sessionId !== void 0) costs.addCall(String(call.sessionId), call.model, catalog.find(call.model), tokens);
1299
+ },
1300
+ refused: (problem) => bridge.noteProblem(problem)
1301
+ });
1302
+ ctx.llm.registerAdapter([ROUTE], adapter);
1303
+ const rerouted = /* @__PURE__ */ new WeakSet();
1304
+ ctx.on("llm/stream", (options, next) => {
1305
+ const utility = preferences.current.utilityModel;
1306
+ const purpose = options.purpose;
1307
+ if (!(utility !== null && options.provider === "routerai" && options.model !== utility && purpose !== void 0 && UTILITY_PURPOSES.has(purpose) && !rerouted.has(options) && catalog.find(utility) !== void 0)) return next();
1308
+ const { reasoningEffort: _dropped, ...rest } = options;
1309
+ const replacement = {
1310
+ ...rest,
1311
+ model: utility
1312
+ };
1313
+ rerouted.add(replacement);
1314
+ return ctx.llm.stream(replacement);
1315
+ });
1316
+ ctx.on("loader/volatile-update", routeChanged);
1317
+ ctx.on("credentials/reference-updated", (ref) => {
1318
+ if (ref === credential) ctx.emit("llm/models-updated", ROUTE);
1319
+ });
1320
+ ctx.effect(() => {
1321
+ const stopWatching = [catalog.watch(routeChanged), preferences.watch(routeChanged)];
1322
+ preferences.restore();
1323
+ costs.restore();
1324
+ catalog.restore().then(() => catalog.sync()).then(() => {
1325
+ if (catalog.lastError !== void 0) ctx.logger.warn(`[routerai] ${catalog.lastError}`);
1326
+ });
1327
+ const timer = setInterval(() => {
1328
+ catalog.sync();
1329
+ }, hours(config.catalogRefreshHours));
1330
+ return () => {
1331
+ clearInterval(timer);
1332
+ for (const stop of stopWatching) stop();
1333
+ costs.flush();
1334
+ };
1335
+ }, "routerai: state");
1336
+ ctx.inject(["connection"], (scope) => {
1337
+ for (const endpoint of bridge.endpoints()) scope.effect(() => scope.connection.fetch.register({
1338
+ ...endpoint,
1339
+ requestBody: "buffered"
1340
+ }), `routerai: ${endpoint.path}`);
1341
+ });
1342
+ ctx.inject(["commands"], (scope) => {
1343
+ registerSlashCommand(scope, {
1344
+ apiBase,
1345
+ credentialName: config.apiKeyEnv,
1346
+ readKey,
1347
+ storeKey,
1348
+ forgetKey,
1349
+ catalog,
1350
+ pickerModels: () => preferences.pickerModels()
1351
+ });
1352
+ });
1353
+ }
1354
+ export { Config, apply, inject, name };