@goodandready/dsh-subscriptions 0.6.0 → 0.6.2

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/history.js CHANGED
@@ -2,9 +2,9 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
 
5
- // #65: история запросов и стоимости. Хранится в
6
- // ~/.dsh/storages/dsh-subscriptions/history.json (JSON-массив, новые сверху).
7
- // Все IO best-effort и синхронные: сбой записи не должен ронять харнесс.
5
+ // #65: request and cost history. Stored in
6
+ // ~/.dsh/storages/dsh-subscriptions/history.json (JSON array, newest first).
7
+ // All IO is best-effort and synchronous: a write failure must never crash the harness.
8
8
 
9
9
  export const DEFAULT_HISTORY_DIR = join(homedir(), '.dsh', 'storages', 'dsh-subscriptions')
10
10
 
@@ -42,7 +42,7 @@ export class HistoryStore {
42
42
  } catch { /* best-effort */ }
43
43
  }
44
44
 
45
- /** Добавить одну запись. Возвращает длину истории. */
45
+ /** Add one entry. Returns the history length. */
46
46
  add(entry) {
47
47
  if (!entry || typeof entry !== 'object') return this.rows.length
48
48
  this.rows.unshift({ ts: Date.now(), ...entry })
@@ -51,7 +51,7 @@ export class HistoryStore {
51
51
  return this.rows.length
52
52
  }
53
53
 
54
- /** Последние N записей (новые сверху). */
54
+ /** Last N entries (newest first). */
55
55
  recent(n) {
56
56
  return this.rows.slice(0, n)
57
57
  }
package/lib/http.js CHANGED
@@ -80,3 +80,10 @@ export function safeJsonHandler(fn) {
80
80
  }
81
81
  }
82
82
  }
83
+
84
+ export function escapeHtml(text) {
85
+ return String(text)
86
+ .replace(/&/g, '&')
87
+ .replace(/</g, '&lt;')
88
+ .replace(/>/g, '&gt;')
89
+ }
package/lib/images.js CHANGED
@@ -1,26 +1,26 @@
1
- // Генерация картинок на подписке.
1
+ // Image generation on a subscription.
2
2
  //
3
- // Здесь только протокол: куда идти, что послать и как прочитать ответ. Всё
4
- // остальное сохранение файла, вложение в разговор, карточка дело плагина
5
- // генерации; этот плагин лишь одалживает свой аккаунт.
3
+ // This module only implements the protocol: where to go, what to send and how to read the response. Everything
4
+ // else - saving the file, attaching it to the conversation, the card - is the job of the generation
5
+ // plugin; this plugin only lends its account.
6
6
  //
7
- // Токен наружу не отдаётся: плагин объявляет службу внутри процесса, а не
8
- // маршрут в сети. Харнесс на этой машине доступен без пароля, и ручка,
9
- // раздающая живой токен подписки, была бы дырой пошире тех, что мы закрывали.
7
+ // The token never leaves the process: the plugin exposes an in-process service, not
8
+ // a network route. The harness on this machine is reachable without a password, and an endpoint
9
+ // handing out a live subscription token would be a wider hole than the ones we already closed.
10
10
 
11
- /** Куда уходит запрос у подписки ChatGPT. */
11
+ /** Where the ChatGPT subscription request goes. */
12
12
  export const CODEX_URL = 'https://chatgpt.com/backend-api/codex/images/generations'
13
- /** Модель, которую отдаёт этот адрес. */
13
+ /** The model served by this endpoint. */
14
14
  export const CODEX_MODEL = 'gpt-image-2'
15
- /** Куда уходит запрос у подписки Grok. */
15
+ /** Where the Grok subscription request goes. */
16
16
  export const GROK_URL = 'https://api.x.ai/v1/images/generations'
17
- /** Модель, которую отдаёт этот адрес. */
17
+ /** The model served by this endpoint. */
18
18
  export const GROK_MODEL = 'grok-imagine-image-2.0'
19
19
 
20
- /** Размеры, которые понимает ChatGPT. */
20
+ /** Sizes understood by ChatGPT. */
21
21
  export const SIZES = ['1024x1024', '1024x1536', '1536x1024', 'auto']
22
22
 
23
- /** Grok мыслит не размерами, а соотношением сторон. */
23
+ /** Grok thinks in aspect ratios, not sizes. */
24
24
  const GROK_ASPECT = {
25
25
  '1024x1024': '1:1',
26
26
  '1024x1536': '2:3',
@@ -30,7 +30,7 @@ const GROK_ASPECT = {
30
30
 
31
31
  export function codexBody({ prompt, size, quality }) {
32
32
  const text = String(prompt || '').trim()
33
- if (!text) throw new Error('нужен непустой запрос')
33
+ if (!text) throw new Error('prompt must be a non-empty string')
34
34
  return {
35
35
  prompt: text,
36
36
  model: CODEX_MODEL,
@@ -41,8 +41,8 @@ export function codexBody({ prompt, size, quality }) {
41
41
 
42
42
  export function grokBody({ prompt, size, quality }) {
43
43
  const text = String(prompt || '').trim()
44
- if (!text) throw new Error('нужен непустой запрос')
45
- // У Grok качество всего двух ступеней: высокое складывается со средним.
44
+ if (!text) throw new Error('prompt must be a non-empty string')
45
+ // Grok has only two quality tiers: high is composed from medium.
46
46
  const level = quality === 'low' ? 'low'
47
47
  : (quality === 'medium' || quality === 'high') ? 'medium'
48
48
  : undefined
@@ -55,7 +55,7 @@ export function grokBody({ prompt, size, quality }) {
55
55
  }
56
56
  }
57
57
 
58
- /** Разбор ответа: обе стороны отвечают одинаково. */
58
+ /** Response parsing: both sides reply in the same shape. */
59
59
  export function parseImages(payload) {
60
60
  const body = payload && typeof payload === 'object' ? payload : {}
61
61
  const rows = Array.isArray(body.data) ? body.data : []
@@ -70,15 +70,15 @@ export function parseImages(payload) {
70
70
  : {}),
71
71
  })
72
72
  }
73
- if (!images.length) throw new Error('в ответе нет картинок')
73
+ if (!images.length) throw new Error('response contains no images')
74
74
  return images
75
75
  }
76
76
 
77
77
  /**
78
- * Один запрос к нужному адресу с заголовками этого провайдера.
78
+ * One request to the right endpoint with this provider's headers.
79
79
  *
80
80
  * @param options {{provider, prompt, size, quality, session, fetchImpl, signal}}
81
- * session уже освежённый блок токенов: accessToken и, для ChatGPT, accountId.
81
+ * session - already refreshed token block: accessToken and, for ChatGPT, accountId.
82
82
  */
83
83
  export async function generateOnce(options) {
84
84
  const { provider, session, fetchImpl, signal } = options
@@ -88,7 +88,7 @@ export async function generateOnce(options) {
88
88
  const headers = isCodex
89
89
  ? {
90
90
  authorization: `Bearer ${session.accessToken}`,
91
- // ChatGPT различает аккаунты отдельным заголовком, без него отвечает отказом.
91
+ // ChatGPT distinguishes accounts with a separate header; without it the request is rejected.
92
92
  'chatgpt-account-id': session.accountId || '',
93
93
  originator: 'codex_cli_rs',
94
94
  'content-type': 'application/json',
package/lib/index.js CHANGED
@@ -61,8 +61,8 @@ export function apply(ctx, config) {
61
61
  })
62
62
  })
63
63
 
64
- // Регистрация кастомных вендоров из Config. Вызывается при старте и при
65
- // каждой смене конфига (replace) реестр пересобирается с нуля.
64
+ // Registering custom vendors from Config. Runs at startup and on
65
+ // every config change (replace) - the registry is rebuilt from scratch.
66
66
  function syncCustomVendors() {
67
67
  let profiles
68
68
  try {
@@ -104,7 +104,7 @@ export function apply(ctx, config) {
104
104
  onLimitNotice: (provider, ref, win, threshold) => {
105
105
  if (!live().notifyLimits) return
106
106
  const label = win.ru || win.en || win.id
107
- try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider} ${ref}: лимит ${label} на ${threshold}%`) } catch {}
107
+ try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider} ${ref}: limit ${label} at ${threshold}%`) } catch {}
108
108
  try { ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }) } catch {}
109
109
  },
110
110
  })
@@ -141,7 +141,7 @@ export function apply(ctx, config) {
141
141
  if (!cfg.ollamaFallback || !models.length) throw err
142
142
  const model = cfg.ollamaFallbackModel || models[0].id
143
143
  try { ctx.emit && ctx.emit('subscriptions.ollama-fallback', { provider, model, reason: err && err.code || 'EXHAUSTED' }) } catch {}
144
- try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: все аккаунты исчерпаны (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
144
+ try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: all accounts exhausted (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
145
145
  try {
146
146
  recordHistory({
147
147
  provider: 'ollama',
@@ -181,15 +181,15 @@ export function apply(ctx, config) {
181
181
  hideDeprecatedModels: () => !!live().hideDeprecatedModels,
182
182
  })
183
183
 
184
- // Служба генерации картинок на подписке.
184
+ // Subscription-backed image generation service.
185
185
  //
186
- // Наружу отдаётся действие, а не токен: маршрут в сети раздавал бы живой
187
- // ключ доступа каждому, кто дотянется до харнесса, а служба живёт внутри
188
- // процесса и видна только другим плагинам. Обновлением токена по-прежнему
189
- // занимается один хозяин этот плагин.
186
+ // An action is exposed, not a token: a network route would hand a live
187
+ // access key to anyone reaching the harness, while the service lives inside
188
+ // the process and is visible only to sibling plugins. Token refresh stays
189
+ // with a single owner - this plugin.
190
190
  ctx.effect(() => ctx.provide('subscriptions', subscriptions), 'dsh-subscriptions: subscriptions service')
191
191
  ctx.effect(() => ctx.provide('subscriptionImages', {
192
- /** Провайдеры, у которых есть вход прямо сейчас. */
192
+ /** Providers with an active session right now. */
193
193
  async available() {
194
194
  const logged = await store.loggedInProviders()
195
195
  return ['codex', 'grok'].filter((name) => logged && logged[name])
@@ -202,13 +202,13 @@ export function apply(ctx, config) {
202
202
  async generate(request) {
203
203
  const provider = request && request.provider
204
204
  if (provider !== 'codex' && provider !== 'grok') {
205
- throw new Error(`неизвестный провайдер подписки: ${provider}`)
205
+ throw new Error(`unknown subscription provider: ${provider}`)
206
206
  }
207
207
  const accounts = await store.listAccounts(provider)
208
208
  const slot = (accounts || []).find((row) => row && row.ref)
209
- if (!slot) throw new Error(`нет входа в ${provider}: войдите в разделе «Подписки»`)
209
+ if (!slot) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
210
210
  const raw = await store.resolveRaw(slot.ref)
211
- if (!raw) throw new Error(`нет входа в ${provider}: войдите в разделе «Подписки»`)
211
+ if (!raw) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
212
212
  const session = await store.ensureFresh(provider, parseBlob(raw), slot.ref)
213
213
  return generateOnce({
214
214
  provider,
@@ -220,7 +220,7 @@ export function apply(ctx, config) {
220
220
  signal: request.signal,
221
221
  })
222
222
  },
223
- }), 'dsh-subscriptions: служба генерации картинок')
223
+ }), 'dsh-subscriptions: image generation service')
224
224
  const pending = new Map()
225
225
  const adapter = new SubscriptionAdapter({
226
226
  listAccounts: (provider) => store.listAccounts(provider),
@@ -466,8 +466,8 @@ export function apply(ctx, config) {
466
466
  ctx.effect(() => {
467
467
  syncAdapter().catch(() => { /* first paint */ })
468
468
  syncOllama().catch(() => { /* first paint */ })
469
- // #75: eager refresh usage на старте, чтобы windows (5h/7d) появились в blob сразу
470
- // и активная подписка в чипе сразу показывала 5h/7d/..., а не ждала probeInterval.
469
+ // #75: eager usage refresh at startup so the windows (5h/7d) land in the blob immediately
470
+ // and the active subscription chip shows 5h/7d/... right away instead of waiting for probeInterval.
471
471
  const eager = async () => {
472
472
  try {
473
473
  for (const slot of normalizeSlots(live().slots)) {
@@ -516,9 +516,9 @@ export function apply(ctx, config) {
516
516
  return () => clear(id)
517
517
  }, 'dsh-subscriptions: refresh ahead')
518
518
 
519
- // Фоновый health-check: раз в N минут прогоняет дешёвый check по всем
520
- // подключённым аккаунтам. Мёртвые помечаются через describeRef, cooldown
521
- // не ставится (как в /check).
519
+ // Background health-check: every N minutes runs a cheap check across all
520
+ // connected accounts. Dead ones are flagged via describeRef, no cooldown
521
+ // is set (same as /check).
522
522
  ctx.effect(() => {
523
523
  const tick = async () => {
524
524
  const cfg = live()
package/lib/loopback.js CHANGED
@@ -1,21 +1,21 @@
1
1
  import http from 'node:http'
2
2
 
3
- // #89: временный loopback-сервер для перехвата OAuth callback.
4
- // Слушает на зарегистрированном у провайдера redirect_uri (порт фиксирован
5
- // вендором: codex localhost:1455, grok 127.0.0.1:56121). Принимает GET с
6
- // code/state, отдаёт HTML-страницу и завершается после первого запроса.
3
+ // #89: temporary loopback server to catch the OAuth callback.
4
+ // Listens on the redirect_uri registered with the provider (vendor-fixed
5
+ // port: codex localhost:1455, grok 127.0.0.1:56121). Accepts a GET with
6
+ // code/state, serves an HTML page and shuts down after the first request.
7
7
 
8
8
  const OK_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>'
9
9
  const ERR_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Login failed. Return to Settings and paste the redirected URL.</p>'
10
10
 
11
11
  /**
12
- * Поднять loopback-сервер и дождаться callback.
12
+ * Start the loopback server and wait for the callback.
13
13
  * @param {object} opts
14
- * @param {string} opts.redirectUri зарегистрированный redirect_uri (localhost/127.0.0.1)
15
- * @param {number} opts.timeoutMs время жизни сервера
14
+ * @param {string} opts.redirectUri registered redirect_uri (localhost/127.0.0.1)
15
+ * @param {number} opts.timeoutMs server lifetime
16
16
  * @param {(query: URLSearchParams) => Promise<string>} opts.onCode
17
- * вызывается с query callback-запроса; возвращает HTML для браузера.
18
- * Бросок исключения = ошибка авторизации (отдаётся ERR_HTML).
17
+ * called with the callback request query; returns HTML for the browser.
18
+ * Throwing means an authorization error (ERR_HTML is served).
19
19
  * @returns {Promise<{url: string, close: () => void}>}
20
20
  */
21
21
  export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode }) {
@@ -30,7 +30,7 @@ export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode
30
30
  const promise = new Promise((resolve, reject) => {
31
31
  const server = http.createServer((req, res) => {
32
32
  const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
33
- // Провайдер может редиректить на путь с suffix (например /auth/callback/extra)
33
+ // The provider may redirect to a suffixed path (e.g. /auth/callback/extra)
34
34
  if (!url.pathname.startsWith(path)) {
35
35
  res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
36
36
  res.end(ERR_HTML)
package/lib/ratelimit.js CHANGED
@@ -212,7 +212,7 @@ export function formatQuota(q) {
212
212
  if (!q) return ""
213
213
  const parts = []
214
214
  if (q.remaining!=null && q.limit!=null) parts.push(q.remaining+"/"+q.limit)
215
- else if (q.remaining!=null) parts.push("осталось "+q.remaining)
216
- if (q.resetAt) parts.push("сброс "+new Date(q.resetAt).toLocaleString())
215
+ else if (q.remaining!=null) parts.push("left "+q.remaining)
216
+ if (q.resetAt) parts.push("reset "+new Date(q.resetAt).toLocaleString())
217
217
  return parts.join(" · ")
218
218
  }
package/lib/refs.js CHANGED
@@ -1,4 +1,4 @@
1
- // Встроенные провайдеры + динамические из Config.customVendors.
1
+ // Built-in providers + dynamic ones from Config.customVendors.
2
2
  export const BUILTIN_PROVIDERS = Object.freeze([
3
3
  'codex',
4
4
  'claude',
@@ -8,6 +8,14 @@ export const BUILTIN_PROVIDERS = Object.freeze([
8
8
  'glm',
9
9
  'cursor',
10
10
  'kiro',
11
+ 'copilot',
12
+ 'qwen',
13
+ 'ernie',
14
+ 'spark',
15
+ 'jetbrains',
16
+ 'perplexity',
17
+ 'replit',
18
+ 'cody',
11
19
  ])
12
20
 
13
21
  const dynamicIds = new Set()