@router-ai/dsh-plugin-routerai 0.0.0-stage → 0.2.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,1721 @@
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 { defineTool } from "@deepseek-ai/dsh-tools";
13
+ import { InMemoryCredentialStore, createProvider } from "@earendil-works/pi-ai";
14
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
15
+ // src/links.ts
16
+ const API_BASE = "https://routerai.ru/api/v1";
17
+ const KEYS_PAGE = "https://routerai.ru/settings/keys";
18
+ const BILLING_PAGE = "https://routerai.ru/settings/billing";
19
+ /** Имя инструмента рисования — по нему же браузерная часть подставляет свою карточку вызова. */
20
+ const DRAW_TOOL = "routerai_draw_image";
21
+ /** Папка в рабочем каталоге проекта, куда сохраняются нарисованные картинки. */
22
+ const MEDIA_FOLDER = "routerai-media";
23
+ /** Идентификатор маршрута в harness: так провайдер называется в пикере и конфигурации. */
24
+ const ROUTE = "routerai";
25
+ // src/failures.ts
26
+ const SIGNS = [
27
+ ["key-off", /api key is disabled/i],
28
+ ["key-cap", /spending limit exceeded/i],
29
+ ["no-funds", /недостаточно средств|insufficient balance/i]
30
+ ];
31
+ function recognize(failure) {
32
+ for (const [kind, pattern] of SIGNS) if (pattern.test(failure.message)) return kind;
33
+ return failure.status === 402 ? "no-funds" : void 0;
34
+ }
35
+ function explain(kind, original) {
36
+ switch (kind) {
37
+ case "no-funds": return `На балансе RouterAI закончились деньги. Пополнить счёт: ${BILLING_PAGE}`;
38
+ case "key-off": return `Этот ключ RouterAI отключён. Включить его можно на ${KEYS_PAGE}, получить новый — командой /routerai login`;
39
+ case "key-cap": {
40
+ const numbers = /exceeded:\s*([\d.]+)\s*\/\s*([\d.]+)/i.exec(original);
41
+ return `Ключ RouterAI упёрся в свой лимит расходов${numbers === null ? "" : ` (потрачено ${numbers[1]} ₽ из ${numbers[2]} ₽)`}. Лимит меняется на ${KEYS_PAGE}; команда /routerai login выдаст ключ с новым лимитом`;
42
+ }
43
+ }
44
+ }
45
+ function describeProblem(failure) {
46
+ const kind = recognize(failure);
47
+ return kind === void 0 ? void 0 : {
48
+ kind,
49
+ text: explain(kind, failure.message),
50
+ at: Date.now()
51
+ };
52
+ }
53
+ // src/adapter.ts
54
+ var RouterAiAdapter = class extends PiAiAdapter {
55
+ observer;
56
+ constructor(options, observer) {
57
+ super(options);
58
+ this.observer = observer;
59
+ }
60
+ async listModels(provider) {
61
+ const listed = [];
62
+ for (const model of await super.listModels(provider)) {
63
+ if (!this.observer.offered(model.id)) continue;
64
+ const caption = this.observer.caption(model.id);
65
+ listed.push(caption === "" ? model : {
66
+ ...model,
67
+ description: caption
68
+ });
69
+ }
70
+ return listed;
71
+ }
72
+ stream(options) {
73
+ return this.watch(options, super.stream(options));
74
+ }
75
+ /** Агент «замораживает» вызов на шаг через prepareCall — наблюдать нужно и этот путь. */
76
+ async prepareCall(provider, model, signal) {
77
+ const call = await super.prepareCall(provider, model, signal);
78
+ return {
79
+ ...call,
80
+ stream: (options) => this.watch(options, call.stream(options))
81
+ };
82
+ }
83
+ async *watch(options, source) {
84
+ try {
85
+ for await (const chunk of source) {
86
+ if (chunk.type === "usage") this.observer.spent(options, chunk.usage);
87
+ else if (chunk.type === "finish" && chunk.reason.kind === "error") {
88
+ const problem = describeProblem(chunk.reason.failure);
89
+ if (problem !== void 0) {
90
+ this.observer.refused(problem);
91
+ yield {
92
+ ...chunk,
93
+ reason: {
94
+ kind: "error",
95
+ failure: {
96
+ ...chunk.reason.failure,
97
+ code: "QUOTA",
98
+ message: problem.text
99
+ }
100
+ }
101
+ };
102
+ continue;
103
+ }
104
+ }
105
+ yield chunk;
106
+ }
107
+ } catch (error) {
108
+ const problem = error instanceof LlmError ? describeProblem({
109
+ message: error.message,
110
+ status: error.status
111
+ }) : void 0;
112
+ if (problem === void 0) throw error;
113
+ this.observer.refused(problem);
114
+ throw new LlmError(problem.text, "QUOTA", { cause: error });
115
+ }
116
+ }
117
+ };
118
+ // src/account.ts
119
+ /** Ключи RouterAI начинаются с `sk-`, дальше — urlsafe-base64. */
120
+ function looksLikeApiKey(text) {
121
+ return /^sk-[\w-]{16,200}$/.test(text);
122
+ }
123
+ /** Ключ для показа: начало и конец, середина скрыта. */
124
+ function maskApiKey(key) {
125
+ return key.length < 14 ? "sk-…" : `${key.slice(0, 7)}…${key.slice(-4)}`;
126
+ }
127
+ function number(value) {
128
+ const parsed = typeof value === "string" ? Number(value) : value;
129
+ return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : 0;
130
+ }
131
+ function keyFacts(data) {
132
+ const facts = {
133
+ label: typeof data["name"] === "string" ? data["name"] : "",
134
+ disabled: data["disabled"] === true,
135
+ spent: number(data["usage"]),
136
+ spentThisMonth: number(data["usage_monthly"])
137
+ };
138
+ const amount = number(data["limit"]);
139
+ if (amount > 0) {
140
+ const period = data["limit_reset"];
141
+ facts.cap = {
142
+ amount,
143
+ left: Math.max(0, number(data["limit_remaining"])),
144
+ period: period === "daily" || period === "weekly" || period === "monthly" ? period : "other"
145
+ };
146
+ }
147
+ return facts;
148
+ }
149
+ async function ask(apiBase, path, apiKey) {
150
+ try {
151
+ const response = await fetch(`${apiBase}${path}`, {
152
+ headers: {
153
+ authorization: `Bearer ${apiKey}`,
154
+ accept: "application/json"
155
+ },
156
+ signal: AbortSignal.timeout(15e3)
157
+ });
158
+ if (!response.ok) return {
159
+ ok: false,
160
+ status: response.status,
161
+ note: `${path} → HTTP ${response.status}`
162
+ };
163
+ const body = await response.json();
164
+ return {
165
+ ok: true,
166
+ data: typeof body.data === "object" && body.data !== null ? body.data : {}
167
+ };
168
+ } catch (error) {
169
+ return {
170
+ ok: false,
171
+ status: void 0,
172
+ note: `${path} → ${error instanceof Error ? error.message : String(error)}`
173
+ };
174
+ }
175
+ }
176
+ async function loadAccount(apiBase, apiKey) {
177
+ const [credits, key] = await Promise.all([ask(apiBase, "/credits", apiKey), ask(apiBase, "/key", apiKey)]);
178
+ const view = {
179
+ state: "ok",
180
+ notes: []
181
+ };
182
+ if (credits.ok) view.balance = number(credits.data["credits"]);
183
+ if (key.ok) view.key = keyFacts(key.data);
184
+ const failed = [credits, key].filter((reply) => !reply.ok);
185
+ view.notes = failed.map((reply) => reply.note);
186
+ if (failed.some((reply) => reply.status === 401 || reply.status === 403)) view.state = "rejected";
187
+ else if (failed.length === 2) view.state = "offline";
188
+ else if (failed.length === 1) view.state = "partial";
189
+ return view;
190
+ }
191
+ // src/storage.ts
192
+ /** Коды, с которыми Windows отказывает в подмене файла, пока его держит другой процесс. */
193
+ const BUSY_CODES = [
194
+ "EBUSY",
195
+ "EPERM",
196
+ "EACCES"
197
+ ];
198
+ const RENAME_ATTEMPTS = 10;
199
+ let sequence = 0;
200
+ async function swapIn(temporary, target) {
201
+ for (let attempt = 1;; attempt += 1) try {
202
+ await rename(temporary, target);
203
+ return;
204
+ } catch (error) {
205
+ const code = error.code;
206
+ if (!(process.platform === "win32" && code !== void 0 && BUSY_CODES.includes(code)) || attempt === RENAME_ATTEMPTS) throw error;
207
+ await new Promise((resolve) => setTimeout(resolve, 40 * attempt));
208
+ }
209
+ }
210
+ var JsonFile = class {
211
+ path;
212
+ accept;
213
+ /** Записи одного файла выстраиваются в очередь: последняя запись побеждает предсказуемо. */
214
+ queue = Promise.resolve();
215
+ /**
216
+ * @param path - путь к файлу.
217
+ * @param accept - проверка прочитанного: возвращает значение или undefined, если документ не годится.
218
+ */
219
+ constructor(path, accept) {
220
+ this.path = path;
221
+ this.accept = accept;
222
+ }
223
+ /** Прочитать документ; undefined, если файла нет, он повреждён или не прошёл проверку. */
224
+ async read() {
225
+ let text;
226
+ try {
227
+ text = await readFile(this.path, "utf8");
228
+ } catch {
229
+ return;
230
+ }
231
+ try {
232
+ return this.accept(JSON.parse(text));
233
+ } catch {
234
+ return;
235
+ }
236
+ }
237
+ write(value) {
238
+ const body = `${JSON.stringify(value, null, 1)}\n`;
239
+ const job = this.queue.then(() => this.replace(body));
240
+ this.queue = job.catch(() => {});
241
+ return job;
242
+ }
243
+ async replace(body) {
244
+ await mkdir(dirname(this.path), { recursive: true });
245
+ sequence += 1;
246
+ const temporary = `${this.path}.${process.pid}-${sequence}.part`;
247
+ try {
248
+ await writeFile(temporary, body, "utf8");
249
+ await swapIn(temporary, this.path);
250
+ } catch (error) {
251
+ await unlink(temporary).catch(() => {});
252
+ throw error;
253
+ }
254
+ }
255
+ };
256
+ // src/costs.ts
257
+ /** Стоимость одного вызова по цене модели; undefined, если цены нет. */
258
+ function priceCall(card, tokens) {
259
+ const price = card?.price;
260
+ if (price === void 0) return void 0;
261
+ 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;
262
+ }
263
+ /** Какая часть входа пришла из кэша; undefined, пока входа не было. */
264
+ function cachedShare(tokens) {
265
+ const input = tokens.input + tokens.cached + tokens.cacheWrites;
266
+ return input > 0 ? tokens.cached / input : void 0;
267
+ }
268
+ const KEEP_SESSIONS = 300;
269
+ const FLUSH_AFTER_MS = 1500;
270
+ function blank() {
271
+ return {
272
+ total: 0,
273
+ calls: 0,
274
+ unpriced: 0,
275
+ last: null,
276
+ tokens: {
277
+ input: 0,
278
+ cached: 0,
279
+ cacheWrites: 0,
280
+ output: 0
281
+ },
282
+ perModel: {},
283
+ touchedAt: 0
284
+ };
285
+ }
286
+ var CostBook = class {
287
+ onWriteError;
288
+ sessions = /* @__PURE__ */ new Map();
289
+ flushTimer;
290
+ file;
291
+ constructor(path, onWriteError = () => {}) {
292
+ this.onWriteError = onWriteError;
293
+ this.file = new JsonFile(path, (raw) => typeof raw === "object" && raw !== null ? raw : void 0);
294
+ }
295
+ async restore() {
296
+ const saved = await this.file.read();
297
+ if (saved !== void 0) this.sessions = new Map(Object.entries(saved));
298
+ }
299
+ of(sessionId) {
300
+ return this.sessions.get(sessionId);
301
+ }
302
+ /** Учесть вызов чат-модели. */
303
+ addCall(sessionId, model, card, tokens) {
304
+ const rub = priceCall(card, tokens);
305
+ const entry = this.open(sessionId);
306
+ entry.calls += 1;
307
+ if (rub === void 0) entry.unpriced += 1;
308
+ entry.total += rub ?? 0;
309
+ entry.last = {
310
+ model,
311
+ rub: rub ?? 0
312
+ };
313
+ entry.tokens.input += tokens.inputTokens ?? 0;
314
+ entry.tokens.cached += tokens.cacheReadTokens ?? 0;
315
+ entry.tokens.cacheWrites += tokens.cacheWriteTokens ?? 0;
316
+ entry.tokens.output += tokens.outputTokens ?? 0;
317
+ const share = entry.perModel[model] ?? {
318
+ calls: 0,
319
+ rub: 0
320
+ };
321
+ entry.perModel[model] = {
322
+ calls: share.calls + 1,
323
+ rub: share.rub + (rub ?? 0)
324
+ };
325
+ this.close(sessionId, entry);
326
+ }
327
+ /** Учесть готовую сумму — например, картинку: RouterAI сам сообщает её стоимость. */
328
+ addCharge(sessionId, model, rub) {
329
+ const entry = this.open(sessionId);
330
+ entry.total += rub;
331
+ entry.last = {
332
+ model,
333
+ rub
334
+ };
335
+ const share = entry.perModel[model] ?? {
336
+ calls: 0,
337
+ rub: 0
338
+ };
339
+ entry.perModel[model] = {
340
+ calls: share.calls + 1,
341
+ rub: share.rub + rub
342
+ };
343
+ this.close(sessionId, entry);
344
+ }
345
+ open(sessionId) {
346
+ const existing = this.sessions.get(sessionId);
347
+ return existing === void 0 ? blank() : structuredClone(existing);
348
+ }
349
+ close(sessionId, entry) {
350
+ entry.touchedAt = Date.now();
351
+ this.sessions.delete(sessionId);
352
+ this.sessions.set(sessionId, entry);
353
+ for (const stale of this.sessions.keys()) {
354
+ if (this.sessions.size <= KEEP_SESSIONS) break;
355
+ this.sessions.delete(stale);
356
+ }
357
+ clearTimeout(this.flushTimer);
358
+ this.flushTimer = setTimeout(() => {
359
+ this.flush();
360
+ }, FLUSH_AFTER_MS);
361
+ }
362
+ async flush() {
363
+ clearTimeout(this.flushTimer);
364
+ try {
365
+ await this.file.write(Object.fromEntries(this.sessions));
366
+ } catch (error) {
367
+ this.onWriteError(error);
368
+ }
369
+ }
370
+ };
371
+ // src/signin.ts
372
+ /**
373
+ * Под этим именем приложение видно пользователю на странице согласия. RouterAI привязывает
374
+ * выданный ключ к имени приложения и при повторном входе перевыпускает его, а не создаёт новый,
375
+ * поэтому имя должно оставаться постоянным.
376
+ */
377
+ const CLIENT_NAME = "DeepSeek Harness";
378
+ const WAIT_LIMIT_MS = 9e5;
379
+ const RETURN_PATH = "/routerai/return";
380
+ const urlSafe = (bytes) => bytes.toString("base64url");
381
+ function newChallenge() {
382
+ const secret = urlSafe(randomBytes(40));
383
+ return {
384
+ secret,
385
+ digest: urlSafe(createHash("sha256").update(secret).digest()),
386
+ ticket: urlSafe(randomBytes(18))
387
+ };
388
+ }
389
+ /** Адрес страницы согласия. Она живёт на сайте, а не под /api. */
390
+ function consentUrl(apiBase, challenge, returnTo) {
391
+ const page = new URL("/auth", apiBase);
392
+ page.search = new URLSearchParams({
393
+ app_name: CLIENT_NAME,
394
+ key_label: CLIENT_NAME,
395
+ callback_url: returnTo,
396
+ code_challenge: challenge.digest,
397
+ code_challenge_method: "S256",
398
+ state: challenge.ticket
399
+ }).toString();
400
+ return page.href;
401
+ }
402
+ async function redeemCode(apiBase, code, challenge, returnTo) {
403
+ const response = await fetch(`${apiBase}/auth/keys`, {
404
+ method: "POST",
405
+ headers: {
406
+ "content-type": "application/json",
407
+ accept: "application/json"
408
+ },
409
+ body: JSON.stringify({
410
+ code,
411
+ code_verifier: challenge.secret,
412
+ code_challenge_method: "S256",
413
+ callback_url: returnTo
414
+ }),
415
+ signal: AbortSignal.timeout(2e4)
416
+ });
417
+ const reply = await response.json().catch(() => ({}));
418
+ if (response.ok && typeof reply["key"] === "string" && reply["key"] !== "") return reply["key"];
419
+ const reason = reply["error_description"] ?? reply["error"] ?? `HTTP ${response.status}`;
420
+ throw new Error(`RouterAI не выдал ключ: ${String(reason)}`);
421
+ }
422
+ function answer(response, status, headline, detail) {
423
+ const safe = (text) => text.replace(/[<>&"]/g, (symbol) => `&#${symbol.charCodeAt(0)};`);
424
+ response.writeHead(status, {
425
+ "content-type": "text/html; charset=utf-8",
426
+ "cache-control": "no-store"
427
+ });
428
+ response.end(`<!doctype html><html lang="ru"><meta charset="utf-8"><title>RouterAI</title>
429
+ <body style="margin:0;min-height:100vh;display:grid;place-items:center;font-family:system-ui,sans-serif;background:#f5f7fb;color:#1b2333">
430
+ <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>`);
431
+ }
432
+ /** Начать вход: поднять локальный приёмник ответа и вернуть адрес страницы согласия. */
433
+ async function startSignIn(apiBase) {
434
+ const challenge = newChallenge();
435
+ let returnTo = "";
436
+ let finish = () => {};
437
+ const key = new Promise((resolve, reject) => {
438
+ finish = (outcome) => "key" in outcome ? resolve(outcome.key) : reject(outcome.error);
439
+ });
440
+ key.catch(() => {});
441
+ const handle = async (request, response) => {
442
+ const target = new URL(request.url ?? "/", returnTo);
443
+ if (target.pathname !== RETURN_PATH) {
444
+ answer(response, 404, "Страница не найдена", "Этот адрес служит только для возврата со страницы входа RouterAI.");
445
+ return;
446
+ }
447
+ try {
448
+ if (target.searchParams.get("state") !== challenge.ticket) throw new Error("Ответ не относится к начатому входу. Запустите вход ещё раз.");
449
+ if (target.searchParams.get("error") === "access_denied") throw new Error("Вы отклонили доступ на странице RouterAI.");
450
+ const code = target.searchParams.get("code");
451
+ if (code === null || code === "") throw new Error(`RouterAI вернул ошибку: ${target.searchParams.get("error") ?? "нет кода авторизации"}`);
452
+ const issued = await redeemCode(apiBase, code, challenge, returnTo);
453
+ answer(response, 200, "Готово", "Ключ RouterAI сохранён в DeepSeek Harness. Эту вкладку можно закрыть.");
454
+ finish({ key: issued });
455
+ } catch (error) {
456
+ const problem = error instanceof Error ? error : new Error(String(error));
457
+ answer(response, 400, "Войти не получилось", problem.message);
458
+ finish({ error: problem });
459
+ }
460
+ };
461
+ const server = createServer((request, response) => {
462
+ handle(request, response);
463
+ });
464
+ await new Promise((resolve, reject) => {
465
+ server.once("error", reject);
466
+ server.listen(0, "127.0.0.1", resolve);
467
+ });
468
+ returnTo = `http://127.0.0.1:${server.address().port}${RETURN_PATH}`;
469
+ const timeout = setTimeout(() => {
470
+ finish({ error: /* @__PURE__ */ new Error("Вход не был подтверждён за 15 минут.") });
471
+ }, WAIT_LIMIT_MS);
472
+ const shutdown = () => {
473
+ clearTimeout(timeout);
474
+ server.close();
475
+ server.closeAllConnections();
476
+ };
477
+ key.then(shutdown, shutdown);
478
+ return {
479
+ url: consentUrl(apiBase, challenge, returnTo),
480
+ key,
481
+ cancel: () => {
482
+ finish({ error: /* @__PURE__ */ new Error("Вход отменён.") });
483
+ }
484
+ };
485
+ }
486
+ /** Как на этой системе открыть адрес в браузере по умолчанию. */
487
+ function browserLauncher(url, platform = process.platform) {
488
+ if (platform === "win32") return {
489
+ program: "explorer.exe",
490
+ args: [url]
491
+ };
492
+ return {
493
+ program: platform === "darwin" ? "open" : "xdg-open",
494
+ args: [url]
495
+ };
496
+ }
497
+ /** Попытаться открыть браузер. Не получилось — не беда: адрес показан пользователю. */
498
+ function openBrowser(url) {
499
+ const { program, args } = browserLauncher(url);
500
+ try {
501
+ const child = spawn(program, args, {
502
+ detached: true,
503
+ stdio: "ignore",
504
+ windowsHide: true
505
+ });
506
+ child.once("error", () => {});
507
+ child.unref();
508
+ } catch {}
509
+ }
510
+ // src/bridge.ts
511
+ const BRIDGE_ROOT = "/api/routerai";
512
+ const ACCOUNT_FRESH_MS = 3e4;
513
+ const PROBLEM_SHOWN_MS = 12e5;
514
+ const BODY_LIMIT = 1e5;
515
+ var Bridge = class {
516
+ deps;
517
+ signIn = { stage: "idle" };
518
+ pending;
519
+ accountCache;
520
+ problem = null;
521
+ constructor(deps) {
522
+ this.deps = deps;
523
+ }
524
+ /** RouterAI отказал из-за денег — интерфейс покажет предупреждение. */
525
+ noteProblem(problem) {
526
+ this.problem = problem;
527
+ this.accountCache = void 0;
528
+ }
529
+ /** Вызов прошёл — предупреждение о деньгах больше не актуально. */
530
+ noteSuccess() {
531
+ this.problem = null;
532
+ }
533
+ async overview(fresh) {
534
+ const key = await this.deps.readKey();
535
+ const preferences = this.deps.preferences;
536
+ return {
537
+ connected: key !== void 0,
538
+ keyMask: key === void 0 ? null : maskApiKey(key),
539
+ signIn: this.signIn,
540
+ account: key === void 0 ? null : await this.account(key, fresh),
541
+ picker: {
542
+ models: [...preferences.pickerModels()],
543
+ custom: preferences.customized
544
+ },
545
+ utilityModel: preferences.current.utilityModel,
546
+ lowBalance: preferences.current.lowBalance,
547
+ imageModel: preferences.current.imageModel,
548
+ speechModel: preferences.current.speechModel,
549
+ problem: this.problem !== null && Date.now() - this.problem.at < PROBLEM_SHOWN_MS ? this.problem : null
550
+ };
551
+ }
552
+ async account(key, fresh) {
553
+ const cached = this.accountCache;
554
+ if (!fresh && cached !== void 0 && cached.forKey === key && Date.now() - cached.at < ACCOUNT_FRESH_MS) return cached.view;
555
+ const view = await loadAccount(this.deps.apiBase, key);
556
+ this.accountCache = {
557
+ forKey: key,
558
+ at: Date.now(),
559
+ view
560
+ };
561
+ return view;
562
+ }
563
+ async beginSignIn() {
564
+ this.pending?.cancel();
565
+ const attempt = await startSignIn(this.deps.apiBase);
566
+ this.pending = attempt;
567
+ this.signIn = {
568
+ stage: "waiting",
569
+ url: attempt.url
570
+ };
571
+ openBrowser(attempt.url);
572
+ attempt.key.then(async (key) => {
573
+ await this.deps.storeKey(key);
574
+ this.accountCache = void 0;
575
+ this.problem = null;
576
+ if (this.pending === attempt) this.signIn = { stage: "idle" };
577
+ }, (error) => {
578
+ if (this.pending !== attempt) return;
579
+ const reason = error instanceof Error ? error.message : String(error);
580
+ this.signIn = {
581
+ stage: "failed",
582
+ reason
583
+ };
584
+ this.deps.warn(`вход не завершён: ${reason}`);
585
+ }).finally(() => {
586
+ if (this.pending === attempt) this.pending = void 0;
587
+ });
588
+ return { url: attempt.url };
589
+ }
590
+ abandonSignIn() {
591
+ const attempt = this.pending;
592
+ this.pending = void 0;
593
+ this.signIn = { stage: "idle" };
594
+ attempt?.cancel();
595
+ }
596
+ endpoints() {
597
+ const reply = (body, status = 200) => Response.json(body, {
598
+ status,
599
+ headers: { "cache-control": "no-store" }
600
+ });
601
+ const input = async (request) => {
602
+ const text = await request.text();
603
+ if (text.length > BODY_LIMIT) throw new Error("Слишком большой запрос");
604
+ const parsed = text === "" ? {} : JSON.parse(text);
605
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Ожидался JSON-объект");
606
+ return parsed;
607
+ };
608
+ const param = (request, name) => new URL(request.url).searchParams.get(name);
609
+ return Object.entries({
610
+ overview: ["GET", (request) => this.overview(param(request, "fresh") === "1")],
611
+ catalog: ["GET", async () => {
612
+ await this.deps.catalog.sync();
613
+ return { models: this.deps.catalog.all() };
614
+ }],
615
+ "session-cost": ["GET", async (request) => {
616
+ const cost = this.deps.costs.of(param(request, "id") ?? "");
617
+ return { cost: cost === void 0 ? null : {
618
+ ...cost,
619
+ cachedShare: cachedShare(cost.tokens) ?? null
620
+ } };
621
+ }],
622
+ "sign-in": ["POST", () => this.beginSignIn()],
623
+ "sign-in/cancel": ["POST", async () => {
624
+ this.abandonSignIn();
625
+ return {};
626
+ }],
627
+ "sign-out": ["POST", async () => {
628
+ this.abandonSignIn();
629
+ await this.deps.forgetKey();
630
+ this.accountCache = void 0;
631
+ return {};
632
+ }],
633
+ key: ["POST", async (request) => {
634
+ const key = String((await input(request))["key"] ?? "").trim();
635
+ if (!looksLikeApiKey(key)) throw new Error("Это не похоже на ключ RouterAI: он начинается с sk-");
636
+ await this.deps.storeKey(key);
637
+ this.accountCache = void 0;
638
+ this.problem = null;
639
+ return {};
640
+ }],
641
+ preferences: ["POST", async (request) => {
642
+ await this.deps.preferences.change(await input(request));
643
+ return {};
644
+ }]
645
+ }).map(([name, [method, handler]]) => ({
646
+ path: `${BRIDGE_ROOT}/${name}`,
647
+ methods: [method],
648
+ fetch: async (request) => {
649
+ try {
650
+ return reply(await handler(request));
651
+ } catch (error) {
652
+ return reply({ error: error instanceof Error ? error.message : String(error) }, 400);
653
+ }
654
+ }
655
+ }));
656
+ }
657
+ };
658
+ // src/catalog.ts
659
+ const OUTPUTS = [
660
+ "text",
661
+ "image",
662
+ "transcription"
663
+ ];
664
+ const isChatModel = (card) => card.makes.includes("text");
665
+ const SLUG = /^~?[a-z0-9][\w.-]*\/[a-z0-9][\w.:-]*$/i;
666
+ function isModelSlug(value) {
667
+ return typeof value === "string" && value.length <= 160 && SLUG.test(value);
668
+ }
669
+ function bag(value) {
670
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
671
+ }
672
+ function strings(value) {
673
+ return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
674
+ }
675
+ function amount(value) {
676
+ const parsed = typeof value === "string" ? Number(value) : value;
677
+ return typeof parsed === "number" && Number.isFinite(parsed) && parsed >= 0 ? parsed : void 0;
678
+ }
679
+ /** Каталог отдаёт цену за единицу; в «рубли за миллион» переводим только то, что тарифицируется по токенам. */
680
+ function tokenPrice(pricing, units) {
681
+ const perMillion = (field) => {
682
+ const unit = units[field];
683
+ if (unit !== void 0 && unit !== "token") return void 0;
684
+ const value = amount(pricing[field]);
685
+ return value === void 0 ? void 0 : value * 1e6;
686
+ };
687
+ const input = perMillion("prompt");
688
+ const output = perMillion("completion");
689
+ if (input === void 0 || output === void 0) return void 0;
690
+ const price = {
691
+ input,
692
+ output
693
+ };
694
+ const cachedInput = perMillion("input_cache_read");
695
+ const cacheWrite = perMillion("input_cache_write");
696
+ if (cachedInput !== void 0) price.cachedInput = cachedInput;
697
+ if (cacheWrite !== void 0) price.cacheWrite = cacheWrite;
698
+ return price;
699
+ }
700
+ /** Цена за картинку из `image_pricing`; модели с оплатой по токенам или мегапикселям так не оценить. */
701
+ function firstImagePrice(pricing) {
702
+ if (!Array.isArray(pricing)) return void 0;
703
+ const known = pricing.map(bag).filter((entry) => entry["unit"] === "image").map((entry) => amount(entry["price"])).filter((value) => value !== void 0);
704
+ return known.length === 0 ? void 0 : Math.min(...known);
705
+ }
706
+ /** «Anthropic: Claude Sonnet 5.5» → производитель и название по отдельности. */
707
+ function splitName(name, slug) {
708
+ const colon = name.indexOf(": ");
709
+ if (colon > 0 && colon <= 40) return {
710
+ maker: name.slice(0, colon),
711
+ title: name.slice(colon + 2).trim() || slug
712
+ };
713
+ const vendor = slug.replace(/^~/, "").split("/")[0] ?? "";
714
+ return {
715
+ maker: vendor.charAt(0).toUpperCase() + vendor.slice(1),
716
+ title: name.trim() || slug
717
+ };
718
+ }
719
+ /** Одна запись ответа; undefined для повреждённых записей и моделей, с которыми плагин не работает (эмбеддинги, видео, озвучка). */
720
+ function readModelCard(entry) {
721
+ const raw = bag(entry);
722
+ if (!isModelSlug(raw["id"])) return void 0;
723
+ const architecture = bag(raw["architecture"]);
724
+ const declared = strings(architecture["output_modalities"]);
725
+ const makes = declared.length === 0 ? ["text"] : OUTPUTS.filter((output) => declared.includes(output));
726
+ if (makes.length === 0) return void 0;
727
+ const parameters = strings(raw["supported_parameters"]);
728
+ const slug = raw["id"];
729
+ const card = {
730
+ slug,
731
+ ...splitName(typeof raw["name"] === "string" ? raw["name"] : "", slug),
732
+ alias: slug.startsWith("~"),
733
+ tools: parameters.includes("tools"),
734
+ reasoning: parameters.includes("reasoning") || parameters.includes("reasoning_effort"),
735
+ vision: strings(architecture["input_modalities"]).includes("image"),
736
+ makes
737
+ };
738
+ const window = amount(raw["context_length"]);
739
+ if (window !== void 0 && window > 0) card.window = window;
740
+ const price = tokenPrice(bag(raw["pricing"]), bag(raw["pricing_units"]));
741
+ if (price !== void 0) card.price = price;
742
+ const perImage = firstImagePrice(raw["image_pricing"]);
743
+ if (perImage !== void 0) card.perImage = perImage;
744
+ if (bag(raw["pricing_units"])["seconds"] === "second") {
745
+ const perSecond = amount(bag(raw["pricing"])["seconds"]);
746
+ if (perSecond !== void 0) card.perSecond = perSecond;
747
+ }
748
+ return card;
749
+ }
750
+ /** Ответ `GET /models` целиком. */
751
+ function readModelList(payload) {
752
+ const entries = bag(payload)["data"];
753
+ if (!Array.isArray(entries)) return [];
754
+ const cards = [];
755
+ for (const entry of entries) {
756
+ const card = readModelCard(entry);
757
+ if (card !== void 0) cards.push(card);
758
+ }
759
+ return cards;
760
+ }
761
+ async function downloadModelList(apiBase, signal) {
762
+ const deadline = AbortSignal.timeout(6e4);
763
+ const response = await fetch(`${apiBase}/models`, {
764
+ headers: { accept: "application/json" },
765
+ signal: signal === void 0 ? deadline : AbortSignal.any([signal, deadline])
766
+ });
767
+ if (!response.ok) throw new Error(`каталог RouterAI недоступен: HTTP ${response.status}`);
768
+ const cards = readModelList(await response.json());
769
+ if (cards.length === 0) throw new Error("каталог RouterAI вернул пустой список моделей");
770
+ return cards;
771
+ }
772
+ function acceptSnapshot(raw) {
773
+ const value = bag(raw);
774
+ if (value["format"] !== 2 || typeof value["source"] !== "string" || typeof value["savedAt"] !== "number") return void 0;
775
+ if (!Array.isArray(value["cards"])) return void 0;
776
+ const cards = value["cards"].filter((card) => isModelSlug(bag(card)["slug"]) && Array.isArray(bag(card)["makes"]));
777
+ return {
778
+ format: 2,
779
+ source: value["source"],
780
+ savedAt: value["savedAt"],
781
+ cards
782
+ };
783
+ }
784
+ /**
785
+ * Копия каталога в памяти. Чтение всегда мгновенное: маршрут собирает список моделей
786
+ * на каждый запрос и ждать сеть не может. Сеть трогает только `sync()`.
787
+ */
788
+ var ModelCatalog = class {
789
+ options;
790
+ index = /* @__PURE__ */ new Map();
791
+ stamp = 0;
792
+ running;
793
+ listeners = /* @__PURE__ */ new Set();
794
+ disk;
795
+ download;
796
+ clock;
797
+ /** Последняя ошибка обновления — для диагностики в интерфейсе и логе. */
798
+ lastError;
799
+ constructor(options) {
800
+ this.options = options;
801
+ this.disk = options.file === void 0 ? void 0 : new JsonFile(options.file, acceptSnapshot);
802
+ this.download = options.download ?? ((apiBase) => downloadModelList(apiBase));
803
+ this.clock = options.clock ?? Date.now;
804
+ }
805
+ get size() {
806
+ return this.index.size;
807
+ }
808
+ all() {
809
+ return [...this.index.values()];
810
+ }
811
+ find(slug) {
812
+ return this.index.get(slug);
813
+ }
814
+ /** Подписка на замену содержимого; возвращает функцию отписки. */
815
+ watch(listener) {
816
+ this.listeners.add(listener);
817
+ return () => {
818
+ this.listeners.delete(listener);
819
+ };
820
+ }
821
+ /** Поднять копию с диска (если она от того же API). */
822
+ async restore() {
823
+ const saved = await this.disk?.read();
824
+ if (saved === void 0 || saved.source !== this.options.apiBase || saved.cards.length === 0) return;
825
+ this.adopt(saved.cards, saved.savedAt);
826
+ }
827
+ /** Обновить из сети, если копия устарела или её нет. Сбой оставляет прежнюю копию. */
828
+ sync(force = false) {
829
+ if (this.index.size > 0 && this.clock() - this.stamp < this.options.maxAgeMs && !force) return Promise.resolve();
830
+ this.running ??= this.pull().finally(() => {
831
+ this.running = void 0;
832
+ });
833
+ return this.running;
834
+ }
835
+ async pull() {
836
+ try {
837
+ const cards = await this.download(this.options.apiBase);
838
+ this.lastError = void 0;
839
+ this.adopt(cards, this.clock());
840
+ await this.disk?.write({
841
+ format: 2,
842
+ source: this.options.apiBase,
843
+ savedAt: this.stamp,
844
+ cards
845
+ });
846
+ } catch (error) {
847
+ this.lastError = error instanceof Error ? error.message : String(error);
848
+ }
849
+ }
850
+ adopt(cards, stamp) {
851
+ this.index = new Map(cards.map((card) => [card.slug, card]));
852
+ this.stamp = stamp;
853
+ for (const listener of this.listeners) listener();
854
+ }
855
+ };
856
+ // src/media.ts
857
+ var MediaError = class extends Error {
858
+ status;
859
+ constructor(message, status) {
860
+ super(message);
861
+ this.status = status;
862
+ }
863
+ };
864
+ async function post(apiBase, path, apiKey, body, signal, timeoutMs) {
865
+ let response;
866
+ try {
867
+ response = await fetch(`${apiBase}${path}`, {
868
+ method: "POST",
869
+ headers: {
870
+ authorization: `Bearer ${apiKey}`,
871
+ "content-type": "application/json",
872
+ accept: "application/json",
873
+ "X-Title": "DeepSeek Harness"
874
+ },
875
+ body: JSON.stringify(body),
876
+ signal: AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
877
+ });
878
+ } catch (error) {
879
+ if (signal.aborted) throw error;
880
+ throw new MediaError(`RouterAI не ответил: ${error instanceof Error ? error.message : String(error)}`);
881
+ }
882
+ const payload = await response.json().catch(() => null);
883
+ if (!response.ok || payload === null) {
884
+ const problem = payload?.["error"];
885
+ const detail = typeof problem === "string" ? problem : problem?.message;
886
+ throw new MediaError(typeof detail === "string" && detail !== "" ? detail : `RouterAI ответил HTTP ${response.status}`, response.status);
887
+ }
888
+ return payload;
889
+ }
890
+ function cost(payload) {
891
+ const value = payload["usage"]?.cost;
892
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
893
+ }
894
+ /** Тип картинки по первым байтам: RouterAI указывает его не для всех моделей. */
895
+ function sniffPictureType(bytes) {
896
+ const starts = (...signature) => signature.every((byte, index) => bytes[index] === byte);
897
+ if (starts(137, 80, 78, 71)) return "image/png";
898
+ if (starts(255, 216, 255)) return "image/jpeg";
899
+ if (starts(71, 73, 70, 56)) return "image/gif";
900
+ if (starts(82, 73, 70, 70) && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return "image/webp";
901
+ }
902
+ async function drawPictures(apiBase, apiKey, request, signal) {
903
+ const payload = await post(apiBase, "/images", apiKey, {
904
+ model: request.model,
905
+ prompt: request.prompt,
906
+ ...request.aspectRatio === void 0 ? {} : { aspect_ratio: request.aspectRatio },
907
+ ...request.count === void 0 ? {} : { n: request.count }
908
+ }, signal, 24e4);
909
+ const pictures = [];
910
+ for (const item of Array.isArray(payload["data"]) ? payload["data"] : []) {
911
+ if (typeof item?.["b64_json"] !== "string") continue;
912
+ const bytes = new Uint8Array(Buffer.from(item["b64_json"], "base64"));
913
+ const type = sniffPictureType(bytes);
914
+ if (type !== void 0) pictures.push({
915
+ bytes,
916
+ type
917
+ });
918
+ }
919
+ if (pictures.length === 0) throw new MediaError("RouterAI не вернул ни одной картинки в поддерживаемом формате (PNG, JPEG, WebP, GIF)");
920
+ const price = cost(payload);
921
+ return price === void 0 ? { pictures } : {
922
+ pictures,
923
+ cost: price
924
+ };
925
+ }
926
+ /** Длительность записи по заголовку WAV; 0, если заголовок не разобрать. */
927
+ function wavSeconds(wav) {
928
+ if (wav.byteLength < 44) return 0;
929
+ const bytesPerSecond = new DataView(wav.buffer, wav.byteOffset, wav.byteLength).getUint32(28, true);
930
+ return bytesPerSecond > 0 ? (wav.byteLength - 44) / bytesPerSecond : 0;
931
+ }
932
+ /** Распознать запись (WAV). Язык — код ISO-639-1; без него RouterAI определяет язык сам. */
933
+ async function recognizeSpeech(apiBase, apiKey, request, signal) {
934
+ const payload = await post(apiBase, "/audio/transcriptions", apiKey, {
935
+ model: request.model,
936
+ input_audio: {
937
+ data: Buffer.from(request.wav).toString("base64"),
938
+ format: "wav"
939
+ },
940
+ ...request.language === void 0 ? {} : { language: request.language }
941
+ }, signal, 12e4);
942
+ const heard = { text: typeof payload["text"] === "string" ? payload["text"].trim() : "" };
943
+ const seconds = payload["usage"]?.seconds;
944
+ if (typeof seconds === "number" && seconds > 0) heard.seconds = seconds;
945
+ const price = cost(payload);
946
+ if (price !== void 0) heard.cost = price;
947
+ return heard;
948
+ }
949
+ // src/money.ts
950
+ /** Сколько знаков после запятой показать, чтобы сумма не выглядела нулём и не шумела копейками. */
951
+ function fractionDigits(amount) {
952
+ const size = Math.abs(amount);
953
+ if (size === 0 || size >= 100) return 0;
954
+ if (size >= 1) return 2;
955
+ if (size >= .01) return 3;
956
+ return 4;
957
+ }
958
+ function rub(amount) {
959
+ if (amount === null || amount === void 0 || !Number.isFinite(amount)) return "—";
960
+ const digits = fractionDigits(amount);
961
+ return `${new Intl.NumberFormat("ru-RU", {
962
+ minimumFractionDigits: digits,
963
+ maximumFractionDigits: digits
964
+ }).format(amount)} ₽`;
965
+ }
966
+ /** «216 ₽ / 1 079 ₽» — вход и выход за миллион токенов. */
967
+ function pricePair(price) {
968
+ return price === void 0 ? "" : `${rub(price.input)} / ${rub(price.output)}`;
969
+ }
970
+ /** Размер контекстного окна: 1048576 → «1M», 200000 → «200K». */
971
+ function windowLabel(tokens) {
972
+ if (tokens === void 0) return "";
973
+ if (tokens >= 1e6) return `${Math.round(tokens / 1e5) / 10}M`;
974
+ return `${Math.round(tokens / 1e3)}K`;
975
+ }
976
+ // src/draw.ts
977
+ const MOST_PICTURES = 4;
978
+ const EXTENSIONS = {
979
+ "image/png": "png",
980
+ "image/jpeg": "jpg",
981
+ "image/webp": "webp",
982
+ "image/gif": "gif"
983
+ };
984
+ /** Имя файла из пожелания модели или из описания картинки: латиница, цифры, дефисы. */
985
+ function fileStem(wish, prompt, now = /* @__PURE__ */ new Date()) {
986
+ const base = (wish ?? "").toLowerCase().replace(/\.[a-z0-9]+$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
987
+ if (base !== "") return base;
988
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
989
+ const words = prompt.toLowerCase().match(/[a-z0-9]+/g)?.slice(0, 4).join("-") ?? "";
990
+ return words === "" ? `image-${stamp}` : `${words.slice(0, 40)}-${stamp}`;
991
+ }
992
+ function summary(drawn) {
993
+ const count = Math.max(drawn.files.length, drawn.shown.length);
994
+ const parts = [`Готово: ${count === 1 ? "картинка" : `картинок: ${count}`}, модель ${drawn.model}`];
995
+ if (drawn.cost !== null) parts.push(`стоимость ${rub(drawn.cost)}`);
996
+ if (drawn.files.length > 0) parts.push(`файлы: ${drawn.files.join(", ")}`);
997
+ return `${parts.join("; ")}.`;
998
+ }
999
+ const pictures = (shown) => shown.map((attachment) => ({
1000
+ type: "image",
1001
+ attachment
1002
+ }));
1003
+ function registerDrawTool(ctx, deps) {
1004
+ const tool = defineTool({
1005
+ name: DRAW_TOOL,
1006
+ description: `Нарисовать картинку по текстовому описанию (иллюстрация, логотип, макет, схема). Картинка появится в чате и сохранится в папку ${MEDIA_FOLDER} рабочего каталога. Вызов платный — используй, когда пользователь просит изображение.`,
1007
+ parameters: {
1008
+ prompt: {
1009
+ type: "string",
1010
+ required: true,
1011
+ description: "Подробное описание картинки: что изображено, стиль, цвета, композиция. Надписи на картинке указывай дословно."
1012
+ },
1013
+ aspect_ratio: {
1014
+ type: "string",
1015
+ description: "Соотношение сторон, например 1:1, 16:9, 9:16, 4:3. По умолчанию выбирает модель."
1016
+ },
1017
+ count: {
1018
+ type: "integer",
1019
+ description: `Сколько вариантов нарисовать, от 1 до ${MOST_PICTURES}. По умолчанию 1.`
1020
+ },
1021
+ file_name: {
1022
+ type: "string",
1023
+ description: "Имя файла без расширения, латиницей. По умолчанию составляется из описания."
1024
+ }
1025
+ },
1026
+ output: {
1027
+ schema: {
1028
+ type: "object",
1029
+ additionalProperties: false,
1030
+ properties: {
1031
+ model: {
1032
+ type: "string",
1033
+ required: true
1034
+ },
1035
+ cost: {
1036
+ oneOf: [{ type: "number" }, { type: "null" }],
1037
+ required: true
1038
+ },
1039
+ files: {
1040
+ type: "array",
1041
+ items: { type: "string" },
1042
+ required: true
1043
+ },
1044
+ shown: {
1045
+ type: "array",
1046
+ items: { type: "json" },
1047
+ required: true
1048
+ }
1049
+ }
1050
+ },
1051
+ render: (_args, value) => {
1052
+ const drawn = value;
1053
+ return [{
1054
+ type: "text",
1055
+ text: summary(drawn)
1056
+ }, ...pictures(drawn.shown)];
1057
+ },
1058
+ presentationMeta: (_args, value) => {
1059
+ const drawn = value;
1060
+ return {
1061
+ model: drawn.model,
1062
+ cost: drawn.cost,
1063
+ files: drawn.files
1064
+ };
1065
+ }
1066
+ },
1067
+ timeoutMs: 3e5,
1068
+ isConcurrencySafe: () => true,
1069
+ presentCall: (args) => ({
1070
+ card: "generic",
1071
+ kind: "other",
1072
+ title: `Рисую: ${args.prompt.length > 80 ? `${args.prompt.slice(0, 79)}…` : args.prompt}`
1073
+ }),
1074
+ presentResult: (_args, result) => ({
1075
+ card: "generic",
1076
+ title: "Картинка RouterAI",
1077
+ content: result.content.filter((block) => block.type === "image" || block.type === "text")
1078
+ }),
1079
+ execute: async (args, run) => {
1080
+ const model = deps.imageModel();
1081
+ if (model === null) throw new Error("Рисование картинок выключено в Настройках → RouterAI.");
1082
+ const key = await deps.readKey();
1083
+ if (key === void 0) throw new Error("Нет ключа RouterAI. Войдите: Настройки → RouterAI или команда /routerai login.");
1084
+ const count = Math.min(MOST_PICTURES, Math.max(1, args.count ?? 1));
1085
+ const result = await drawPictures(deps.apiBase, key, {
1086
+ model,
1087
+ prompt: args.prompt,
1088
+ count,
1089
+ ...args.aspect_ratio === void 0 ? {} : { aspectRatio: args.aspect_ratio }
1090
+ }, run.signal);
1091
+ const stem = fileStem(args.file_name, args.prompt);
1092
+ const names = result.pictures.map((picture, index) => `${stem}${result.pictures.length > 1 ? `-${index + 1}` : ""}.${EXTENSIONS[picture.type]}`);
1093
+ const files = [];
1094
+ const workingDirectory = ctx.get("workingDirectory");
1095
+ if (workingDirectory !== void 0 && run.agent !== void 0) {
1096
+ const folder = join(await workingDirectory.ensure(run.agent, run.signal), MEDIA_FOLDER);
1097
+ await mkdir(folder, { recursive: true });
1098
+ for (const [index, picture] of result.pictures.entries()) {
1099
+ const path = join(folder, names[index]);
1100
+ await writeFile(path, picture.bytes);
1101
+ files.push(path);
1102
+ }
1103
+ }
1104
+ const shown = [];
1105
+ const attachments = deps.attachments();
1106
+ if (attachments !== void 0) for (const [index, picture] of result.pictures.entries()) shown.push(await attachments.saveImage({
1107
+ data: picture.bytes,
1108
+ mediaType: picture.type,
1109
+ name: names[index]
1110
+ }));
1111
+ if (result.cost !== void 0 && run.agent !== void 0) deps.charged(String(run.agent.id), model, result.cost);
1112
+ return {
1113
+ model,
1114
+ cost: result.cost ?? null,
1115
+ files,
1116
+ shown
1117
+ };
1118
+ }
1119
+ });
1120
+ ctx.effect(() => ctx.tools.register(tool, { when: () => deps.imageModel() !== null }), "routerai: draw tool");
1121
+ }
1122
+ const INITIAL = {
1123
+ utilityModel: "deepseek/deepseek-v4.1-flash",
1124
+ lowBalance: 100,
1125
+ imageModel: "bytedance-seed/seedream-5-5",
1126
+ speechModel: "openai/whisper-large-v3-turbo"
1127
+ };
1128
+ /** Привести список моделей к каноническому виду или объяснить, что с ним не так. */
1129
+ function cleanModelList(value) {
1130
+ if (!Array.isArray(value)) throw new Error("Список моделей должен быть массивом");
1131
+ const seen = /* @__PURE__ */ new Set();
1132
+ for (const item of value) {
1133
+ if (!isModelSlug(item)) throw new Error(`«${String(item)}» не похоже на идентификатор модели вида vendor/model`);
1134
+ seen.add(item);
1135
+ }
1136
+ if (seen.size > 150) throw new Error(`В пикере может быть не больше 150 моделей`);
1137
+ return [...seen];
1138
+ }
1139
+ function fromDisk(raw) {
1140
+ if (typeof raw !== "object" || raw === null) return void 0;
1141
+ const stored = raw;
1142
+ const result = { ...INITIAL };
1143
+ try {
1144
+ if (stored["models"] !== void 0) result.models = cleanModelList(stored["models"]);
1145
+ } catch {}
1146
+ if (stored["utilityModel"] === null || isModelSlug(stored["utilityModel"])) result.utilityModel = stored["utilityModel"];
1147
+ if (typeof stored["lowBalance"] === "number" && stored["lowBalance"] >= 0) result.lowBalance = stored["lowBalance"];
1148
+ if (stored["imageModel"] === null || isModelSlug(stored["imageModel"])) result.imageModel = stored["imageModel"];
1149
+ if (isModelSlug(stored["speechModel"])) result.speechModel = stored["speechModel"];
1150
+ return result;
1151
+ }
1152
+ var PreferenceStore = class {
1153
+ configuredModels;
1154
+ value = { ...INITIAL };
1155
+ file;
1156
+ listeners = /* @__PURE__ */ new Set();
1157
+ constructor(path, configuredModels) {
1158
+ this.configuredModels = configuredModels;
1159
+ this.file = new JsonFile(path, fromDisk);
1160
+ }
1161
+ get current() {
1162
+ return this.value;
1163
+ }
1164
+ /** Модели пикера: выбор пользователя или, если его нет, список из конфигурации. */
1165
+ pickerModels() {
1166
+ return this.value.models ?? this.configuredModels();
1167
+ }
1168
+ get customized() {
1169
+ return this.value.models !== void 0;
1170
+ }
1171
+ watch(listener) {
1172
+ this.listeners.add(listener);
1173
+ return () => {
1174
+ this.listeners.delete(listener);
1175
+ };
1176
+ }
1177
+ async restore() {
1178
+ const saved = await this.file.read();
1179
+ if (saved === void 0) return;
1180
+ this.value = saved;
1181
+ this.notify();
1182
+ }
1183
+ /** Применить изменения из интерфейса; неверные значения отклоняются с понятной ошибкой. */
1184
+ async change(patch) {
1185
+ const next = { ...this.value };
1186
+ if ("models" in patch) next.models = cleanModelList(patch["models"]);
1187
+ if ("utilityModel" in patch) {
1188
+ const model = patch["utilityModel"];
1189
+ if (model !== null && !isModelSlug(model)) throw new Error("Служебная модель: ожидался идентификатор vendor/model или null");
1190
+ next.utilityModel = model;
1191
+ }
1192
+ if ("lowBalance" in patch) {
1193
+ const threshold = patch["lowBalance"];
1194
+ if (typeof threshold !== "number" || !Number.isFinite(threshold) || threshold < 0) throw new Error("Порог баланса: ожидалось неотрицательное число");
1195
+ next.lowBalance = threshold;
1196
+ }
1197
+ if ("imageModel" in patch) {
1198
+ const model = patch["imageModel"];
1199
+ if (model !== null && !isModelSlug(model)) throw new Error("Модель для картинок: ожидался идентификатор vendor/model или null");
1200
+ next.imageModel = model;
1201
+ }
1202
+ if ("speechModel" in patch) {
1203
+ const model = patch["speechModel"];
1204
+ if (!isModelSlug(model)) throw new Error("Модель распознавания речи: ожидался идентификатор vendor/model");
1205
+ next.speechModel = model;
1206
+ }
1207
+ await this.file.write(next);
1208
+ this.value = next;
1209
+ this.notify();
1210
+ return next;
1211
+ }
1212
+ notify() {
1213
+ for (const listener of this.listeners) listener();
1214
+ }
1215
+ };
1216
+ // src/provider.ts
1217
+ const PROVIDER_NAME = "RouterAI";
1218
+ /** Каталог не сообщает предел вывода; этого хватает на длинные правки и не упирается в лимиты апстримов. */
1219
+ const OUTPUT_CEILING = 32768;
1220
+ /** Окно для модели, о которой каталог ещё ничего не сказал. */
1221
+ const UNKNOWN_WINDOW = 128e3;
1222
+ function wireDialect(slug) {
1223
+ const dialect = {
1224
+ thinkingFormat: "openrouter",
1225
+ supportsReasoningEffort: true,
1226
+ supportsUsageInStreaming: true,
1227
+ maxTokensField: "max_tokens",
1228
+ supportsStore: false,
1229
+ supportsDeveloperRole: false
1230
+ };
1231
+ if (/^~?anthropic\//.test(slug)) dialect.cacheControlFormat = "anthropic";
1232
+ return dialect;
1233
+ }
1234
+ /** RouterAI принимает усилия low/medium/high; прочие уровни harness модели не предлагает. */
1235
+ function thinkingLevels() {
1236
+ return {
1237
+ off: null,
1238
+ minimal: null,
1239
+ low: "low",
1240
+ medium: "medium",
1241
+ high: "high",
1242
+ xhigh: null,
1243
+ max: null
1244
+ };
1245
+ }
1246
+ function describeModel(card, apiBase) {
1247
+ const window = card.window ?? UNKNOWN_WINDOW;
1248
+ return {
1249
+ id: card.slug,
1250
+ name: card.title,
1251
+ provider: ROUTE,
1252
+ api: "openai-completions",
1253
+ baseUrl: apiBase,
1254
+ input: card.vision ? ["text", "image"] : ["text"],
1255
+ contextWindow: window,
1256
+ maxTokens: Math.min(OUTPUT_CEILING, window),
1257
+ reasoning: card.reasoning,
1258
+ cost: {
1259
+ input: 0,
1260
+ output: 0,
1261
+ cacheRead: 0,
1262
+ cacheWrite: 0
1263
+ },
1264
+ compat: wireDialect(card.slug),
1265
+ ...card.reasoning ? { thinkingLevelMap: thinkingLevels() } : {}
1266
+ };
1267
+ }
1268
+ /** Модель из списка пользователя, пока каталог не загрузился: консервативные возможности. */
1269
+ function describeUnknownModel(slug, apiBase) {
1270
+ return describeModel({
1271
+ slug,
1272
+ title: slug,
1273
+ maker: "",
1274
+ alias: slug.startsWith("~"),
1275
+ tools: true,
1276
+ reasoning: false,
1277
+ vision: false,
1278
+ makes: ["text"]
1279
+ }, apiBase);
1280
+ }
1281
+ /**
1282
+ * pi-ai умеет сам искать ключи в окружении и на диске. Этому маршруту это не нужно и вредно:
1283
+ * ключ всегда выдаёт harness, поэтому pi-ai получает пустое хранилище и «слепой» контекст.
1284
+ */
1285
+ const isolatedAuth = {
1286
+ credentials: new InMemoryCredentialStore(),
1287
+ authContext: {
1288
+ env: async () => void 0,
1289
+ fileExists: async () => false
1290
+ }
1291
+ };
1292
+ function buildRoute(models, apiBase, credential) {
1293
+ const provider = createProvider({
1294
+ id: ROUTE,
1295
+ name: PROVIDER_NAME,
1296
+ baseUrl: apiBase,
1297
+ models: [...models],
1298
+ api: openAICompletionsApi(),
1299
+ auth: { apiKey: {
1300
+ name: PROVIDER_NAME,
1301
+ resolve: async ({ credential: given }) => ({
1302
+ auth: given?.key === void 0 ? {} : { apiKey: given.key },
1303
+ source: PROVIDER_NAME
1304
+ })
1305
+ } }
1306
+ });
1307
+ return {
1308
+ provider: ROUTE,
1309
+ displayName: PROVIDER_NAME,
1310
+ apiKeyEnv: credential,
1311
+ piProvider: provider,
1312
+ retryPolicy: resolveRetryPolicy(void 0, "llm-routerai.retryPolicy"),
1313
+ streamIdleTimeoutMs: 3e5,
1314
+ maxRequestImages: 50,
1315
+ maxRequestImageBytes: 20971520,
1316
+ requestImageMaxBytes: 1048576,
1317
+ requestImagePixelBudget: 4194304,
1318
+ imageOffloadCountQuantum: 10,
1319
+ modelErrors: /* @__PURE__ */ new Map(),
1320
+ configuredMaxTokens: /* @__PURE__ */ new Map()
1321
+ };
1322
+ }
1323
+ // src/slash.ts
1324
+ const USAGE = `Команда /routerai:
1325
+ login войти через routerai.ru — ключ выдаст сайт
1326
+ key sk-… сохранить ключ, созданный вручную
1327
+ logout удалить сохранённый ключ
1328
+ status баланс аккаунта и состояние ключа
1329
+ models модели, которые показывает пикер
1330
+ find <текст> поиск по каталогу RouterAI`;
1331
+ const PERIODS = {
1332
+ daily: "в сутки",
1333
+ weekly: "в неделю",
1334
+ monthly: "в месяц",
1335
+ other: ""
1336
+ };
1337
+ /** Состояние аккаунта текстом — для чата. */
1338
+ function accountReport(account) {
1339
+ if (account.state === "rejected") return "RouterAI не принимает этот ключ: его отозвали или удалили. Получите новый командой /routerai login.";
1340
+ if (account.state === "offline") return `Не удалось связаться с RouterAI (${account.notes.join("; ")}).`;
1341
+ const lines = [];
1342
+ if (account.balance !== void 0) lines.push(`На счёте ${rub(account.balance)}.`);
1343
+ const key = account.key;
1344
+ if (key !== void 0) {
1345
+ const name = key.label === "" ? "Ключ" : `Ключ «${key.label}»`;
1346
+ if (key.disabled) lines.push(`${name} отключён — включить можно на ${KEYS_PAGE}.`);
1347
+ lines.push(key.cap === void 0 ? `${name} без лимита расходов.` : `${name}: лимит ${rub(key.cap.amount)} ${PERIODS[key.cap.period]}`.trimEnd() + `, осталось ${rub(key.cap.left)}.`);
1348
+ lines.push(`Потрачено по ключу: ${rub(key.spent)}, за месяц ${rub(key.spentThisMonth)}.`);
1349
+ }
1350
+ lines.push(...account.notes.map((note) => `Часть данных недоступна: ${note}`));
1351
+ lines.push(`Пополнение: ${BILLING_PAGE}`);
1352
+ return lines.join("\n");
1353
+ }
1354
+ function modelLine(card) {
1355
+ return [
1356
+ card.slug,
1357
+ pricePair(card.price) && `${pricePair(card.price)} за 1М токенов`,
1358
+ windowLabel(card.window),
1359
+ card.tools ? "" : "без инструментов"
1360
+ ].filter((fact) => fact !== "").join(" · ");
1361
+ }
1362
+ function registerSlashCommand(ctx, deps) {
1363
+ const good = (text) => ({
1364
+ kind: "success",
1365
+ text
1366
+ });
1367
+ const bad = (text) => ({
1368
+ kind: "error",
1369
+ text
1370
+ });
1371
+ async function run(verb, argument, signal) {
1372
+ switch (verb) {
1373
+ case "login": {
1374
+ const attempt = await startSignIn(deps.apiBase);
1375
+ signal.addEventListener("abort", attempt.cancel, { once: true });
1376
+ openBrowser(attempt.url);
1377
+ ctx.logger.info(`[routerai] страница входа: ${attempt.url}`);
1378
+ const key = await attempt.key;
1379
+ await deps.storeKey(key);
1380
+ return good(`Вы вошли в RouterAI, ключ сохранён как ${deps.credentialName}.\n${accountReport(await loadAccount(deps.apiBase, key))}`);
1381
+ }
1382
+ case "key":
1383
+ if (!looksLikeApiKey(argument)) return bad(`Нужен ключ RouterAI вида sk-… Создать его можно на ${KEYS_PAGE}`);
1384
+ await deps.storeKey(argument);
1385
+ return good(`Ключ сохранён как ${deps.credentialName}.`);
1386
+ case "logout":
1387
+ await deps.forgetKey();
1388
+ return good(`Ключ ${deps.credentialName} удалён из DeepSeek Harness. Если он задан переменной окружения или в .env, уберите его и оттуда.`);
1389
+ case "status": {
1390
+ const key = await deps.readKey();
1391
+ if (key === void 0) return bad("Ключа RouterAI пока нет. Войдите: /routerai login");
1392
+ return good(accountReport(await loadAccount(deps.apiBase, key)));
1393
+ }
1394
+ case "models": {
1395
+ await deps.catalog.sync();
1396
+ const lines = deps.pickerModels().map((slug) => {
1397
+ const card = deps.catalog.find(slug);
1398
+ return card === void 0 ? `${slug} · в каталоге не найдена` : modelLine(card);
1399
+ });
1400
+ return good(`${lines.join("\n")}\n\nСписок меняется в Настройках → RouterAI.`);
1401
+ }
1402
+ case "find": {
1403
+ await deps.catalog.sync();
1404
+ const needle = argument.toLowerCase();
1405
+ const hits = deps.catalog.all().filter(isChatModel).filter((card) => `${card.slug} ${card.maker} ${card.title}`.toLowerCase().includes(needle));
1406
+ if (hits.length === 0) return bad(`По запросу «${argument}» в каталоге RouterAI ничего нет.`);
1407
+ const shown = hits.slice(0, 30).map(modelLine);
1408
+ if (hits.length > shown.length) shown.push(`…и ещё ${hits.length - shown.length}. Уточните запрос.`);
1409
+ return good(shown.join("\n"));
1410
+ }
1411
+ default: return good(USAGE);
1412
+ }
1413
+ }
1414
+ ctx.commands.register({
1415
+ name: "routerai",
1416
+ description: "RouterAI: вход, баланс, модели",
1417
+ input: { hint: "login | key sk-… | logout | status | models | find <текст>" },
1418
+ recordInput: false,
1419
+ handler: async ({ rawInput, signal }) => {
1420
+ const text = rawInput.trim();
1421
+ const gap = text.search(/\s/);
1422
+ const verb = (gap === -1 ? text : text.slice(0, gap)).toLowerCase();
1423
+ const argument = gap === -1 ? "" : text.slice(gap).trim();
1424
+ try {
1425
+ return await run(verb, argument, signal);
1426
+ } catch (error) {
1427
+ return bad(error instanceof Error ? error.message : String(error));
1428
+ }
1429
+ }
1430
+ });
1431
+ }
1432
+ // src/speech.ts
1433
+ const SPEECH_PROVIDER = "routerai";
1434
+ /** Языки, которые предлагает выбор в интерфейсе; `auto` оставляет определение языка модели. */
1435
+ const LANGUAGES = [
1436
+ "auto",
1437
+ "ru",
1438
+ "en",
1439
+ "de",
1440
+ "fr",
1441
+ "es",
1442
+ "it",
1443
+ "pt",
1444
+ "tr",
1445
+ "uk",
1446
+ "kk",
1447
+ "zh",
1448
+ "ja",
1449
+ "ko"
1450
+ ];
1451
+ function registerSpeechProvider(ctx, deps) {
1452
+ ctx.effect(() => {
1453
+ const unregister = ctx.speechToText.register({
1454
+ info: {
1455
+ id: SPEECH_PROVIDER,
1456
+ name: "RouterAI",
1457
+ location: "cloud",
1458
+ languages: LANGUAGES
1459
+ },
1460
+ transcribe: async (input, signal) => {
1461
+ const key = await deps.readKey();
1462
+ if (key === void 0) throw new Error("Нет ключа RouterAI. Войдите: Настройки → RouterAI или команда /routerai login.");
1463
+ const started = Date.now();
1464
+ const heard = await recognizeSpeech(deps.apiBase, key, {
1465
+ model: deps.speechModel(),
1466
+ wav: input.audio,
1467
+ ...input.language === "auto" || input.language === "" ? {} : { language: input.language }
1468
+ }, signal);
1469
+ return {
1470
+ text: heard.text,
1471
+ audioSeconds: heard.seconds ?? wavSeconds(input.audio),
1472
+ inferenceSeconds: (Date.now() - started) / 1e3
1473
+ };
1474
+ }
1475
+ });
1476
+ return () => {
1477
+ unregister();
1478
+ };
1479
+ }, "routerai: speech provider");
1480
+ }
1481
+ /** Что видит в пикере новый пользователь. */
1482
+ const STARTER_MODELS = [
1483
+ {
1484
+ key: "everyday",
1485
+ models: [
1486
+ "anthropic/claude-sonnet-5.5",
1487
+ "deepseek/deepseek-v4.1-flash",
1488
+ "openai/gpt-6.1-sol",
1489
+ "google/gemini-4-argon",
1490
+ "anthropic/claude-opus-5.5",
1491
+ "moonshotai/kimi-k3.1-pro",
1492
+ "z-ai/glm-5.3-flash"
1493
+ ]
1494
+ },
1495
+ {
1496
+ key: "coding",
1497
+ models: [
1498
+ "anthropic/claude-opus-5.5",
1499
+ "google/gemini-4-argon",
1500
+ "z-ai/glm-5.3-flash",
1501
+ "openai/gpt-6.1-sol",
1502
+ "moonshotai/kimi-k3.1-pro"
1503
+ ]
1504
+ },
1505
+ {
1506
+ key: "budget",
1507
+ models: ["deepseek/deepseek-v4.1-flash", "z-ai/glm-5.3-flash"]
1508
+ }
1509
+ ][0].models;
1510
+ // src/index.ts
1511
+ /**
1512
+ * RouterAI — провайдер моделей для DeepSeek Harness.
1513
+ *
1514
+ * Плагин регистрирует маршрут `routerai`: запросы уходят в OpenAI-совместимый API RouterAI
1515
+ * через штатный адаптер pi-ai. Сверх этого плагин следит за каталогом моделей и ценами,
1516
+ * помогает получить ключ через браузер, показывает баланс и считает стоимость сессий,
1517
+ * даёт агенту инструмент для рисования картинок и распознаёт речь для голосового ввода.
1518
+ *
1519
+ * Настройка из файла (обычно всё делается в Настройках → RouterAI):
1520
+ *
1521
+ * ```yaml
1522
+ * - id: llm-routerai
1523
+ * config:
1524
+ * models: [anthropic/claude-sonnet-5.5, deepseek/deepseek-v4.1-flash]
1525
+ * ```
1526
+ *
1527
+ * @module @router-ai/dsh-plugin-routerai
1528
+ */
1529
+ const name = "llm-routerai";
1530
+ const inject = ["llm"];
1531
+ const Config = z.object({
1532
+ models: z.array(z.string()).default([...STARTER_MODELS]).volatile().description("Модели RouterAI в пикере, по порядку. Выбор в Настройках → RouterAI имеет приоритет."),
1533
+ apiKeyEnv: z.string().default("ROUTERAI_API_KEY").description("Имя, под которым хранится ключ RouterAI (sk-…): учётные данные harness, переменная окружения или .env."),
1534
+ baseURL: z.string().default(API_BASE).description("Адрес API RouterAI."),
1535
+ catalogRefreshHours: z.number().min(.25).default(4).description("Как часто обновлять каталог моделей и цены, в часах.")
1536
+ });
1537
+ /** Каталог состояния плагина внутри дома harness. */
1538
+ function stateDir() {
1539
+ return join(process.env["DSH_HOME"] ?? join(homedir(), ".dsh"), "routerai");
1540
+ }
1541
+ /** Запросы на заголовок сессии и сжатие контекста: им хватает недорогой модели. */
1542
+ const UTILITY_PURPOSES = /* @__PURE__ */ new Set(["session-title", "compaction"]);
1543
+ function apply(ctx, config) {
1544
+ const apiBase = config.baseURL.replace(/\/+$/, "");
1545
+ const credential = credentialRef(config.apiKeyEnv);
1546
+ const home = stateDir();
1547
+ const hours = (value) => value * 36e5;
1548
+ const catalog = new ModelCatalog({
1549
+ apiBase,
1550
+ file: join(home, "catalog.json"),
1551
+ maxAgeMs: hours(config.catalogRefreshHours)
1552
+ });
1553
+ const preferences = new PreferenceStore(join(home, "preferences.json"), () => config.models.get() ?? []);
1554
+ const costs = new CostBook(join(home, "costs.json"), (error) => ctx.logger.warn("[routerai] не удалось сохранить расход: %s", String(error)));
1555
+ const optional = {};
1556
+ const adopt = (service) => {
1557
+ ctx.inject([service], (scope) => {
1558
+ scope.effect(() => {
1559
+ optional[service] = scope.get(service);
1560
+ return () => {
1561
+ delete optional[service];
1562
+ };
1563
+ }, `routerai: ${service}`);
1564
+ });
1565
+ };
1566
+ adopt("credentials");
1567
+ adopt("attachments");
1568
+ adopt("fs");
1569
+ let route;
1570
+ const warnedAbout = /* @__PURE__ */ new Set();
1571
+ const routeChanged = () => {
1572
+ route = void 0;
1573
+ ctx.emit("llm/models-updated", ROUTE);
1574
+ };
1575
+ const currentRoute = () => {
1576
+ if (route !== void 0) return route;
1577
+ const wanted = new Set(preferences.pickerModels());
1578
+ const utility = preferences.current.utilityModel;
1579
+ if (utility !== null) wanted.add(utility);
1580
+ const models = [];
1581
+ for (const slug of wanted) {
1582
+ const card = catalog.find(slug);
1583
+ if (card !== void 0 && isChatModel(card)) models.push(describeModel(card, apiBase));
1584
+ else if (catalog.size === 0) models.push(describeUnknownModel(slug, apiBase));
1585
+ else if (!warnedAbout.has(slug)) {
1586
+ warnedAbout.add(slug);
1587
+ ctx.logger.warn(`[routerai] ${slug}: в каталоге RouterAI нет такой чат-модели — в пикер она не попадёт`);
1588
+ }
1589
+ }
1590
+ route = /* @__PURE__ */ new Map([[ROUTE, buildRoute(models, apiBase, credential)]]);
1591
+ return route;
1592
+ };
1593
+ const readKey = async () => {
1594
+ const value = (optional.credentials !== void 0 ? await optional.credentials.resolve(credential) : launchEnvironmentOf(ctx).get(credential))?.value;
1595
+ return value === void 0 || value === "" ? void 0 : assertUsableApiKey(value, name, config.apiKeyEnv);
1596
+ };
1597
+ const keyVault = () => {
1598
+ if (optional.credentials === void 0) throw new Error("В этом режиме DeepSeek Harness нет хранилища ключей — задайте ключ переменной окружения.");
1599
+ return optional.credentials;
1600
+ };
1601
+ const storeKey = async (key) => {
1602
+ await keyVault().set(credential, key);
1603
+ ctx.emit("llm/models-updated", ROUTE);
1604
+ };
1605
+ const forgetKey = async () => {
1606
+ await keyVault().unset(credential);
1607
+ ctx.emit("llm/models-updated", ROUTE);
1608
+ };
1609
+ const bridge = new Bridge({
1610
+ apiBase,
1611
+ readKey,
1612
+ storeKey,
1613
+ forgetKey,
1614
+ catalog,
1615
+ preferences,
1616
+ costs,
1617
+ warn: (message) => ctx.logger.warn(`[routerai] ${message}`)
1618
+ });
1619
+ const adapter = new RouterAiAdapter({
1620
+ profiles: currentRoute,
1621
+ auth: isolatedAuth,
1622
+ resolveApiKey: async () => {
1623
+ const key = await readKey();
1624
+ if (key === void 0) throw new LlmError(`Нет ключа RouterAI (${config.apiKeyEnv}). Войдите командой /routerai login или создайте ключ на ${KEYS_PAGE} и сохраните его: /routerai key sk-…`, "MISSING_CREDENTIAL");
1625
+ return key;
1626
+ },
1627
+ prepareHeaders: (_request, headers) => {
1628
+ headers.set("X-Title", "DeepSeek Harness");
1629
+ headers.set("HTTP-Referer", "https://github.com/router-ai/dsh-plugin-routerai");
1630
+ return headers;
1631
+ },
1632
+ resolveAttachments: () => optional.attachments,
1633
+ resolveImageAccess: (store, ref) => resolveImageAttachmentAccess(store, (path) => optional.fs?.processPathFromHostPath(path), ref),
1634
+ onReplayDegrade: ({ model, reason }) => {
1635
+ ctx.logger.warn(`[routerai] ${model}: служебные данные прошлых ответов переданы упрощённо (${reason})`);
1636
+ }
1637
+ }, {
1638
+ caption: (slug) => {
1639
+ const card = catalog.find(slug);
1640
+ if (card === void 0) return "";
1641
+ const price = pricePair(card.price);
1642
+ return [price && `${price} за 1М токенов`, card.window && `контекст ${windowLabel(card.window)}`].filter(Boolean).join(" · ");
1643
+ },
1644
+ offered: (slug) => preferences.pickerModels().includes(slug),
1645
+ spent: (call, tokens) => {
1646
+ bridge.noteSuccess();
1647
+ if (call.sessionId !== void 0) costs.addCall(String(call.sessionId), call.model, catalog.find(call.model), tokens);
1648
+ },
1649
+ refused: (problem) => bridge.noteProblem(problem)
1650
+ });
1651
+ ctx.llm.registerAdapter([ROUTE], adapter);
1652
+ const rerouted = /* @__PURE__ */ new WeakSet();
1653
+ ctx.on("llm/stream", (options, next) => {
1654
+ const utility = preferences.current.utilityModel;
1655
+ const purpose = options.purpose;
1656
+ 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();
1657
+ const { reasoningEffort: _dropped, ...rest } = options;
1658
+ const replacement = {
1659
+ ...rest,
1660
+ model: utility
1661
+ };
1662
+ rerouted.add(replacement);
1663
+ return ctx.llm.stream(replacement);
1664
+ });
1665
+ ctx.on("loader/volatile-update", routeChanged);
1666
+ ctx.on("credentials/reference-updated", (ref) => {
1667
+ if (ref === credential) ctx.emit("llm/models-updated", ROUTE);
1668
+ });
1669
+ ctx.effect(() => {
1670
+ const stopWatching = [catalog.watch(routeChanged), preferences.watch(routeChanged)];
1671
+ preferences.restore();
1672
+ costs.restore();
1673
+ catalog.restore().then(() => catalog.sync()).then(() => {
1674
+ if (catalog.lastError !== void 0) ctx.logger.warn(`[routerai] ${catalog.lastError}`);
1675
+ });
1676
+ const timer = setInterval(() => {
1677
+ catalog.sync();
1678
+ }, hours(config.catalogRefreshHours));
1679
+ return () => {
1680
+ clearInterval(timer);
1681
+ for (const stop of stopWatching) stop();
1682
+ costs.flush();
1683
+ };
1684
+ }, "routerai: state");
1685
+ ctx.inject(["connection"], (scope) => {
1686
+ for (const endpoint of bridge.endpoints()) scope.effect(() => scope.connection.fetch.register({
1687
+ ...endpoint,
1688
+ requestBody: "buffered"
1689
+ }), `routerai: ${endpoint.path}`);
1690
+ });
1691
+ ctx.inject(["tools"], (scope) => {
1692
+ registerDrawTool(scope, {
1693
+ apiBase,
1694
+ readKey,
1695
+ imageModel: () => preferences.current.imageModel,
1696
+ attachments: () => optional.attachments,
1697
+ charged: (sessionId, model, rub) => {
1698
+ costs.addCharge(sessionId, model, rub);
1699
+ }
1700
+ });
1701
+ });
1702
+ ctx.inject(["speechToText"], (scope) => {
1703
+ registerSpeechProvider(scope, {
1704
+ apiBase,
1705
+ readKey,
1706
+ speechModel: () => preferences.current.speechModel
1707
+ });
1708
+ });
1709
+ ctx.inject(["commands"], (scope) => {
1710
+ registerSlashCommand(scope, {
1711
+ apiBase,
1712
+ credentialName: config.apiKeyEnv,
1713
+ readKey,
1714
+ storeKey,
1715
+ forgetKey,
1716
+ catalog,
1717
+ pickerModels: () => preferences.pickerModels()
1718
+ });
1719
+ });
1720
+ }
1721
+ export { Config, apply, inject, name };