@goodandready/dsh-subscriptions 0.6.91 → 0.7.3

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 CHANGED
@@ -12,8 +12,11 @@ import { getVendor, registerCustomVendor, clearCustomVendors } from './vendors/i
12
12
  import { generateOnce, SIZES as IMAGE_SIZES } from './images.js'
13
13
  import { parseBlob } from './blob.js'
14
14
  import { createVendorFromProfile, validateProfiles } from './vendor-factory.js'
15
+ import { registerVendorPack } from './vendor-pack.js'
15
16
  import { registerCustomProviderIds, registerDisplayName } from './refs.js'
16
17
  import { createSubscriptionsService } from './subscriptions.js'
18
+ import { registerSubscriptionTools } from './native-tools.js'
19
+ import { detectConflicts, readProfilePluginNames, setActiveConflicts } from './coexistence.js'
17
20
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
18
21
  import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
19
22
  import { createResetCreditService } from './reset-credits.js'
@@ -39,7 +42,7 @@ export { pushToWebdav, pullFromWebdav, pushToGist, pullFromGist, buildS3SigV4Hea
39
42
  import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from './vault-sync.js'
40
43
 
41
44
  export const name = '@goodandready/dsh-subscriptions'
42
- export const inject = ['llm', 'credentials', 'webServer', 'settings']
45
+ export const inject = ['llm', 'credentials', 'webServer', 'settings', 'tools']
43
46
 
44
47
  const NS = 'dsh-subscriptions'
45
48
 
@@ -75,6 +78,11 @@ const OK_HTML = `<!doctype html>
75
78
 
76
79
  export function apply(ctx, rawConfig) {
77
80
  const logger = typeof ctx.logger === 'function' ? ctx.logger('subscriptions') : (ctx.logger || undefined)
81
+ const conflicts = detectConflicts({ dependencies: readProfilePluginNames() })
82
+ setActiveConflicts(conflicts)
83
+ if (conflicts.length && logger && logger.warn) {
84
+ logger.warn('[dsh-subscriptions] conflicting subscription plugins detected: ' + conflicts.join(', ') + '; keep one provider owner')
85
+ }
78
86
 
79
87
  // rawConfig still holds Volatile boxes, so unwrap before validating.
80
88
  const resolveEntry = () => {
@@ -98,6 +106,17 @@ export function apply(ctx, rawConfig) {
98
106
  if (logger && logger.warn) logger.warn('[dsh-subscriptions] customVendors[' + profile.id + ']: ' + String(e && e.message || e))
99
107
  }
100
108
  }
109
+ for (const pack of (live().providerPacks || [])) {
110
+ try {
111
+ registerVendorPack(pack, {
112
+ registerVendor: registerCustomVendor,
113
+ registerProviderId: registerCustomProviderIds,
114
+ registerDisplayName,
115
+ })
116
+ } catch (e) {
117
+ if (logger && logger.warn) logger.warn('[dsh-subscriptions] providerPacks[' + (pack && pack.id) + ']: ' + String(e && e.message || e))
118
+ }
119
+ }
101
120
  }
102
121
  syncCustomVendors()
103
122
 
@@ -377,6 +396,10 @@ export function apply(ctx, rawConfig) {
377
396
  })
378
397
  },
379
398
  }), 'dsh-subscriptions: image generation service')
399
+ ctx.effect(() => registerSubscriptionTools(ctx, {
400
+ subscriptions,
401
+ images: () => ctx.subscriptionImages,
402
+ }), 'dsh-subscriptions: native tools')
380
403
 
381
404
  const pending = new Map()
382
405
 
package/lib/messages.js CHANGED
@@ -301,7 +301,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
301
301
  ...(vendorCfg && /^(low|medium|high)$/.test(String(vendorCfg.verbosity || ''))
302
302
  ? { text: { verbosity: String(vendorCfg.verbosity) } } : {}),
303
303
  // #92: Fast Mode = 1.5x speed billing tier on the Codex backend.
304
- ...(vendorCfg && vendorCfg.fastMode ? { service_tier: 'priority' } : {}),
304
+ ...((vendorCfg && vendorCfg.fastMode) || options.service_tier === 'priority' ? { service_tier: 'priority' } : {}),
305
305
  ...(options.maxTokens != null ? { max_output_tokens: options.maxTokens } : {}),
306
306
  ...(options.temperature != null ? { temperature: options.temperature } : {}),
307
307
  }
@@ -0,0 +1,49 @@
1
+ function rowsFor(rows, provider) {
2
+ return (Array.isArray(rows) ? rows : []).filter((row) => row && row.provider === provider)
3
+ }
4
+
5
+ const PLAN_RANK = { free: 0, plus: 1, pro: 2, max: 3, team: 3, enterprise: 4 }
6
+
7
+ export function planRank(name) {
8
+ return PLAN_RANK[String(name || '').toLowerCase()] ?? 0
9
+ }
10
+
11
+ export function bestAccountPlan(accounts) {
12
+ let best = null
13
+ for (const account of accounts || []) {
14
+ const plan = account && (account.plan || account.paidTierName)
15
+ if (!plan) continue
16
+ if (!best || planRank(plan) > planRank(best)) best = String(plan)
17
+ }
18
+ return best
19
+ }
20
+
21
+ export function applyModelSettings(provider, models, cfg = {}, accountPlan = null) {
22
+ const list = Array.isArray(models) ? models : []
23
+ const hidden = new Set(rowsFor(cfg.hiddenModels, provider).map((row) => String(row.id)))
24
+ const plans = new Map(rowsFor(cfg.modelPlans, provider).map((row) => [String(row.id), String(row.minPlan || '')]))
25
+ const accountRank = accountPlan ? planRank(accountPlan) : null
26
+ const names = new Map(rowsFor(cfg.modelDisplayNames, provider).map((row) => [String(row.id), String(row.name)]))
27
+ const contexts = new Map(rowsFor(cfg.modelContextWindows, provider).map((row) => [String(row.id), Number(row.contextWindow)]))
28
+ const efforts = new Map(rowsFor(cfg.modelReasoningDefaults, provider).map((row) => [String(row.id), String(row.effort).toLowerCase()]))
29
+ return list
30
+ .filter((model) => {
31
+ if (!model || hidden.has(String(model.id))) return false
32
+ const minPlan = plans.get(String(model.id))
33
+ if (minPlan && accountRank != null && planRank(minPlan) > accountRank) return false
34
+ return true
35
+ })
36
+ .map((model) => {
37
+ const id = String(model.id)
38
+ const minPlan = plans.get(id)
39
+ const contextWindow = contexts.get(id)
40
+ const effort = efforts.get(id)
41
+ return {
42
+ ...model,
43
+ name: names.get(id) || model.name || model.id,
44
+ ...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow } : {}),
45
+ ...(effort ? { reasoning: { ...(model.reasoning || {}), defaultEffort: effort } } : {}),
46
+ ...(minPlan ? { plan: minPlan, minPlan } : {}),
47
+ }
48
+ })
49
+ }
@@ -0,0 +1,220 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ const TOOL_OUTPUT = {
5
+ schema: {
6
+ type: 'object',
7
+ additionalProperties: true,
8
+ properties: { text: { type: 'string' } },
9
+ required: ['text'],
10
+ },
11
+ render(_args, value) {
12
+ return [{ type: 'text', text: String((value && value.text) || '') }]
13
+ },
14
+ }
15
+
16
+ function textResult(text) {
17
+ return { text: String(text) }
18
+ }
19
+
20
+ export function extractResponseText(payload) {
21
+ const body = payload && typeof payload === 'object' ? payload : {}
22
+ if (typeof body.output_text === 'string' && body.output_text) return body.output_text
23
+ if (typeof body.answer === 'string' && body.answer) return body.answer
24
+ if (Array.isArray(body.output)) {
25
+ const parts = []
26
+ for (const item of body.output) {
27
+ for (const content of (item && item.content) || []) {
28
+ if (content && typeof content.text === 'string') parts.push(content.text)
29
+ }
30
+ }
31
+ if (parts.length) return parts.join('\n')
32
+ }
33
+ if (Array.isArray(body.choices)) {
34
+ return body.choices.map((choice) => choice && choice.message && choice.message.content)
35
+ .filter((value) => typeof value === 'string').join('\n')
36
+ }
37
+ return JSON.stringify(body).slice(0, 4000)
38
+ }
39
+
40
+ function outputDir(exec) {
41
+ const cwd = (exec && exec.agent && exec.agent.session && exec.agent.session.header
42
+ && exec.agent.session.header.cwd) || process.cwd()
43
+ return path.join(cwd, 'generated', 'subscriptions')
44
+ }
45
+
46
+ async function saveImages(images, exec) {
47
+ const dir = outputDir(exec)
48
+ await mkdir(dir, { recursive: true })
49
+ const out = []
50
+ for (let i = 0; i < images.length; i += 1) {
51
+ const image = images[i]
52
+ if (!image || typeof image.b64_json !== 'string') continue
53
+ const file = path.join(dir, `image-${Date.now()}-${i}.png`)
54
+ await writeFile(file, Buffer.from(image.b64_json, 'base64'))
55
+ out.push(file)
56
+ }
57
+ return out
58
+ }
59
+
60
+ export function registerSubscriptionTools(ctx, deps) {
61
+ const tools = ctx && ctx.tools
62
+ if (!tools || typeof tools.register !== 'function') return () => {}
63
+ const disposers = []
64
+ const register = (definition) => {
65
+ try {
66
+ const off = tools.register(definition)
67
+ if (typeof off === 'function') disposers.push(off)
68
+ } catch (err) {
69
+ // Another plugin may already own this tool name (for example x_search
70
+ // from a Grok search plugin or image_generate from an image plugin).
71
+ // Keep our remaining tools registered instead of failing the whole plugin.
72
+ if (!/already registered/i.test(String(err && err.message || err))) throw err
73
+ }
74
+ }
75
+
76
+ register({
77
+ name: 'image_generate',
78
+ description: 'Generate an image with the active ChatGPT/Codex or Grok subscription. Returns saved file paths.',
79
+ output: TOOL_OUTPUT,
80
+ parameters: {
81
+ type: 'object',
82
+ properties: {
83
+ prompt: { type: 'string', description: 'Image prompt' },
84
+ provider: { type: 'string', enum: ['gpt', 'codex', 'grok'], description: 'Preferred provider' },
85
+ size: { type: 'string', description: 'Image size, e.g. 1024x1024' },
86
+ quality: { type: 'string', description: 'low, medium or high' },
87
+ },
88
+ required: ['prompt'],
89
+ },
90
+ async execute(args, exec) {
91
+ const images = deps.images && deps.images()
92
+ if (!images || typeof images.generate !== 'function') throw new Error('subscription image service is unavailable')
93
+ const preferred = String((args && args.provider) || 'gpt').toLowerCase()
94
+ const order = preferred === 'grok' ? ['grok', 'codex'] : ['codex', 'grok']
95
+ let lastError = null
96
+ for (const provider of order) {
97
+ try {
98
+ const rows = await images.generate({
99
+ provider,
100
+ prompt: args && args.prompt,
101
+ size: args && args.size,
102
+ quality: args && args.quality,
103
+ signal: exec && exec.signal,
104
+ })
105
+ const files = await saveImages(rows, exec)
106
+ if (!files.length) throw new Error('image response contained no images')
107
+ return textResult(files.map((file) => `Generated image: ${file}`).join('\n'))
108
+ } catch (err) {
109
+ lastError = err
110
+ }
111
+ }
112
+ throw lastError || new Error('no subscription image provider is available')
113
+ },
114
+ })
115
+
116
+ register({
117
+ name: 'x_search',
118
+ description: 'Search X posts through the active Grok subscription and return an answer with citations.',
119
+ output: TOOL_OUTPUT,
120
+ parameters: {
121
+ type: 'object',
122
+ properties: {
123
+ query: { type: 'string', description: 'Search query' },
124
+ model: { type: 'string', description: 'Optional Grok model id' },
125
+ },
126
+ required: ['query'],
127
+ },
128
+ async execute(args, exec) {
129
+ const res = await deps.subscriptions.request({
130
+ provider: 'grok',
131
+ path: '/v1/responses',
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
134
+ body: {
135
+ model: (args && args.model) || 'grok-4.6',
136
+ input: args && args.query,
137
+ tools: [{ type: 'x_search' }],
138
+ stream: false,
139
+ },
140
+ signal: exec && exec.signal,
141
+ })
142
+ const data = await res.json().catch(() => ({}))
143
+ if (!res.ok) throw new Error(`x_search HTTP ${res.status}: ${extractResponseText(data)}`)
144
+ return textResult(extractResponseText(data))
145
+ },
146
+ })
147
+
148
+ register({
149
+ name: 'web_search',
150
+ description: 'Search the web through the active ChatGPT/Codex subscription and return an answer with citations.',
151
+ output: TOOL_OUTPUT,
152
+ parameters: {
153
+ type: 'object',
154
+ properties: {
155
+ query: { type: 'string', description: 'Search query' },
156
+ model: { type: 'string', description: 'Optional Codex model id' },
157
+ },
158
+ required: ['query'],
159
+ },
160
+ async execute(args, exec) {
161
+ const res = await deps.subscriptions.request({
162
+ provider: 'codex',
163
+ path: '/responses',
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
166
+ body: {
167
+ model: (args && args.model) || 'gpt-5.6',
168
+ input: args && args.query,
169
+ tools: [{ type: 'web_search_preview' }],
170
+ stream: false,
171
+ },
172
+ signal: exec && exec.signal,
173
+ })
174
+ const data = await res.json().catch(() => ({}))
175
+ if (!res.ok) throw new Error(`web_search HTTP ${res.status}: ${extractResponseText(data)}`)
176
+ return textResult(extractResponseText(data))
177
+ },
178
+ })
179
+
180
+ register({
181
+ name: 'video_generate',
182
+ description: 'Submit a short video generation request through the active Grok subscription.',
183
+ output: TOOL_OUTPUT,
184
+ parameters: {
185
+ type: 'object',
186
+ properties: {
187
+ prompt: { type: 'string', description: 'Video prompt' },
188
+ duration: { type: 'number', description: 'Duration in seconds (1-15)' },
189
+ aspect_ratio: { type: 'string', description: 'e.g. 16:9, 1:1, 9:16' },
190
+ image_url: { type: 'string', description: 'Optional source image URL' },
191
+ },
192
+ required: ['prompt'],
193
+ },
194
+ async execute(args, exec) {
195
+ const res = await deps.subscriptions.request({
196
+ provider: 'grok',
197
+ path: '/v1/videos',
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
200
+ body: {
201
+ model: 'grok-imagine-video-1.5',
202
+ prompt: args && args.prompt,
203
+ duration: args && args.duration,
204
+ aspect_ratio: args && args.aspect_ratio,
205
+ image_url: args && args.image_url,
206
+ },
207
+ signal: exec && exec.signal,
208
+ })
209
+ const data = await res.json().catch(() => ({}))
210
+ if (!res.ok) throw new Error(`video_generate HTTP ${res.status}: ${extractResponseText(data)}`)
211
+ return textResult(`Video request submitted: ${JSON.stringify(data).slice(0, 1000)}`)
212
+ },
213
+ })
214
+
215
+ return () => {
216
+ for (const off of disposers) {
217
+ try { off() } catch { /* best-effort cleanup */ }
218
+ }
219
+ }
220
+ }
@@ -80,15 +80,25 @@ export function resolveAccurateReset(err, account, provider, nowMs = Date.now(),
80
80
  return targetReset + (Number(safetyBufferMs) || 0)
81
81
  }
82
82
 
83
- const p = String(provider || (account && account.provider) || '').toLowerCase()
84
- const windowDefault = VENDOR_WINDOW_MS[p]
85
- if (windowDefault && err && (err.status === 429 || err.code === 'RATE_LIMIT' || err.code === 'QUOTA')) {
86
- return now + windowDefault + (Number(safetyBufferMs) || 0)
87
- }
88
-
83
+ // #684: a 429 without a disclosed reset is a local backoff, not a whole
84
+ // vendor window. Parking on a guessed 2-24h window burns a healthy account.
89
85
  return null
90
86
  }
91
87
 
88
+ export function nextCooldownMs(accounts, now = Date.now()) {
89
+ const current = Number(now) || Date.now()
90
+ let next = null
91
+ for (const account of accounts || []) {
92
+ if (!account) continue
93
+ const candidates = [account.cooldownUntil, account.quarantineUntil, account.quota && account.quota.resetAt]
94
+ for (const value of candidates) {
95
+ const ts = Number(value)
96
+ if (Number.isFinite(ts) && ts > current) next = next === null ? ts : Math.min(next, ts)
97
+ }
98
+ }
99
+ return next === null ? null : next - current
100
+ }
101
+
92
102
  export function formatRollingCountdown(resetAt, lang = 'en', now = Date.now()) {
93
103
  const ts = Number(resetAt)
94
104
  if (!Number.isFinite(ts) || ts <= 0) return ''
@@ -0,0 +1,28 @@
1
+ export function buildRouteReport(last, accounts, now = Date.now()) {
2
+ if (!last || !last.provider) return null
3
+ const current = Number(now) || Date.now()
4
+ const account = (Array.isArray(accounts) ? accounts : []).find((row) => row && row.ref === last.ref)
5
+ const source = (account && account.quota && account.quota.source) || account && account.quotaSource || 'unknown'
6
+ return {
7
+ at: last.at || last.timestamp || null,
8
+ route: {
9
+ provider: last.provider,
10
+ model: last.model || null,
11
+ path: last.path || null,
12
+ account: last.ref || null,
13
+ },
14
+ billing: {
15
+ provider: last.billingProvider || last.provider,
16
+ route: last.billingRoute || last.path || null,
17
+ },
18
+ quota: {
19
+ usedPercent: account && account.usagePercent != null ? account.usagePercent : null,
20
+ source,
21
+ ageMs: account && account.usageAt ? Math.max(0, current - Number(account.usageAt)) : null,
22
+ resetAt: (account && account.quota && account.quota.resetAt) || null,
23
+ confidence: source === 'unknown' ? 'unknown' : 'measured',
24
+ },
25
+ healthScore: account && account.healthScore != null ? account.healthScore : null,
26
+ stale: Boolean(account && account.usageAt && current - Number(account.usageAt) > 5 * 60 * 1000),
27
+ }
28
+ }
@@ -10,6 +10,10 @@ import {
10
10
  } from '../reasoning-effort.js'
11
11
  import { defaultConcurrencyQueue } from '../concurrency-queue.js'
12
12
  import { calculateAnalytics } from '../analytics.js'
13
+ import { setSpeedMode, getSpeedMode, clearSpeedMode } from '../speed-mode.js'
14
+ import { getActiveConflicts } from '../coexistence.js'
15
+ import { buildUsageRows, buildCallRows, toCsv } from '../usage-export.js'
16
+ import { buildRouteReport } from '../route-report.js'
13
17
  import { runDiagnosticWizard } from '../diagnostic-wizard.js'
14
18
  import { buildEnrichedCatalog } from '../models-catalog.js'
15
19
  // #374: Sensitive GET routes protected by same-origin check
@@ -254,7 +258,8 @@ export function registerStatusRoutes(ctx, state) {
254
258
  labels,
255
259
  expiryNotifyDays: live().expiryNotifyDays,
256
260
  fastMode: !!live().codexFastMode,
257
- composerQuota: String(live().composerQuota || 'off'),
261
+ composerQuota: String(live().composerQuota || 'off'), composerQuotaDisplay: String(live().composerQuotaDisplay || 'current'),
262
+ quotaWidget: String(live().quotaWidget || 'docked'),
258
263
  active,
259
264
  })
260
265
  }),
@@ -297,7 +302,14 @@ export function registerStatusRoutes(ctx, state) {
297
302
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
298
303
  return
299
304
  }
300
- writeJson(res, 200, { ok: true, report: await diagnosticsReport() })
305
+ const accounts = await accountsView()
306
+ const last = history.recent(1)[0]
307
+ writeJson(res, 200, {
308
+ ok: true,
309
+ report: await diagnosticsReport(),
310
+ conflicts: getActiveConflicts(),
311
+ route: buildRouteReport(last, accounts),
312
+ })
301
313
  }),
302
314
  }), 'dsh-subscriptions: /diagnostics')
303
315
 
@@ -436,6 +448,45 @@ export function registerStatusRoutes(ctx, state) {
436
448
  }),
437
449
  }), 'dsh-subscriptions: /history')
438
450
 
451
+ // #687: quota snapshot and per-call ledger export for DSH quota plugins.
452
+ ctx.effect(() => ctx.webServer.register({
453
+ kind: 'exact',
454
+ path: '/dsh-subscriptions/usage',
455
+ handler: safeJsonHandler(async (req, res) => {
456
+ if (req.method !== 'GET') {
457
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
458
+ return
459
+ }
460
+ if (!isTrustedSettingsRequest(req, ctx)) {
461
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
462
+ return
463
+ }
464
+ const q = queryOf(req)
465
+ const type = q.get('type') === 'calls' ? 'calls' : 'accounts'
466
+ const format = String(q.get('format') || 'json').toLowerCase()
467
+ const now = Date.now()
468
+ const accounts = await accountsView()
469
+ const calls = history.recent(500)
470
+ const rows = type === 'calls' ? buildCallRows(calls) : buildUsageRows(accounts, now)
471
+ if (format === 'csv') {
472
+ res.writeHead(200, {
473
+ 'Content-Type': 'text/csv; charset=utf-8',
474
+ 'Content-Disposition': 'attachment; filename="dsh-subscriptions-' + type + '.csv"',
475
+ 'Cache-Control': 'no-store',
476
+ })
477
+ res.end(toCsv(rows))
478
+ return
479
+ }
480
+ writeJson(res, 200, {
481
+ ok: true,
482
+ generatedAt: now,
483
+ type,
484
+ rows,
485
+ summary: { accounts: Array.isArray(accounts) ? accounts.length : 0, calls: Array.isArray(calls) ? calls.length : 0 },
486
+ })
487
+ }),
488
+ }), 'dsh-subscriptions: /usage')
489
+
439
490
  // Telemetry summary route for UI stat cards (requests, latency, success rate)
440
491
  ctx.effect(() => ctx.webServer.register({
441
492
  kind: 'exact',
@@ -716,6 +767,41 @@ export function registerStatusRoutes(ctx, state) {
716
767
  }),
717
768
  }), 'dsh-subscriptions: /queue')
718
769
 
770
+ // #681: per-session Codex Speed/Fast toggle.
771
+ ctx.effect(() => ctx.webServer.register({
772
+ kind: 'exact',
773
+ path: '/dsh-subscriptions/speed',
774
+ handler: safeJsonHandler(async (req, res) => {
775
+ if (!isTrustedSettingsRequest(req, ctx)) {
776
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
777
+ return
778
+ }
779
+ if (req.method === 'GET') {
780
+ const sid = queryOf(req).get('sessionId') || 'default'
781
+ writeJson(res, 200, { ok: true, sessionId: sid, mode: getSpeedMode(sid) || 'standard' })
782
+ return
783
+ }
784
+ if (req.method !== 'POST') {
785
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST only' } })
786
+ return
787
+ }
788
+ let body = {}
789
+ try { body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}') } catch {
790
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
791
+ return
792
+ }
793
+ const sid = String(body.sessionId || 'default').slice(0, 200)
794
+ const mode = String(body.mode || '').toLowerCase()
795
+ if (mode !== 'fast' && mode !== 'standard' && mode !== 'off') {
796
+ writeJson(res, 400, { ok: false, error: { code: 'mode', message: 'mode must be fast or standard' } })
797
+ return
798
+ }
799
+ if (mode === 'fast') setSpeedMode(sid, 'fast')
800
+ else clearSpeedMode(sid)
801
+ writeJson(res, 200, { ok: true, sessionId: sid, mode: mode === 'fast' ? 'fast' : 'standard' })
802
+ }),
803
+ }), 'dsh-subscriptions: /speed')
804
+
719
805
  // #519: Interactive Diagnostic Wizard for accounts and connectivity
720
806
  ctx.effect(() => ctx.webServer.register({
721
807
  kind: 'exact',
@@ -0,0 +1,17 @@
1
+ const modes = new Map()
2
+
3
+ export function setSpeedMode(sessionId, mode) {
4
+ const id = String(sessionId || '').trim()
5
+ if (!id) return
6
+ const value = String(mode || '').toLowerCase()
7
+ if (value === 'fast') modes.set(id, 'fast')
8
+ else modes.delete(id)
9
+ }
10
+
11
+ export function getSpeedMode(sessionId) {
12
+ return modes.get(String(sessionId || '').trim()) || null
13
+ }
14
+
15
+ export function clearSpeedMode(sessionId) {
16
+ modes.delete(String(sessionId || '').trim())
17
+ }
@@ -7,7 +7,7 @@ import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
7
7
  import { withRunawayGuard } from './runaway-guard.js'
8
8
  import { defaultErrorBuffer } from './error-buffer.js'
9
9
  import { withSoftFailureSentinel } from './sentinel.js'
10
- import { resolveAccurateReset } from './reset-clock.js'
10
+ import { resolveAccurateReset, nextCooldownMs } from './reset-clock.js'
11
11
 
12
12
  export async function* streamWithRotation({
13
13
  accounts,
@@ -88,7 +88,15 @@ export async function* streamWithRotation({
88
88
  return
89
89
  }
90
90
  syncTrace('exhausted')
91
- if (lastError) throw lastError
91
+ if (lastError) {
92
+ const wait = nextCooldownMs(pool, nowMs())
93
+ if (wait != null && options && options.rateLimitWait !== false) {
94
+ const maxWait = Number(options.rateLimitMaxWaitMs) || 6 * 3600 * 1000
95
+ lastError.retryAfterMs = Math.min(wait, maxWait)
96
+ lastError.resetAt = nowMs() + lastError.retryAfterMs
97
+ }
98
+ throw lastError
99
+ }
92
100
  const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
93
101
  err.code = 'RATE_LIMIT'
94
102
  throw err
@@ -20,7 +20,7 @@ const OPENAI_COMPAT = [
20
20
  export const ALLOWLIST = {
21
21
  codex: ["/responses", "/models", "/images/generations", "/backend-api/codex/responses", "/backend-api/codex/models", "/backend-api/codex/images/generations"],
22
22
  claude: ["/v1/messages", "/api/oauth/profile", "/api/oauth/usage", "/v1/models"],
23
- grok: ["/v1/models", "/responses", "/v1/responses", "/v1/billing", "/v1/chat/completions", "/images/generations", "/v1/images/generations"],
23
+ grok: ["/v1/models", "/responses", "/v1/responses", "/v1/billing", "/v1/chat/completions", "/images/generations", "/v1/images/generations", "/v1/videos"],
24
24
  antigravity: ["/v1/models", "/v1/generateContent", "/v1/streamGenerateContent", "/v1/loadCodeAssist", "/v1/fetchAvailableModels", "/v1/countTokens", "/v1/internal:loadCodeAssist"],
25
25
  kimi: OPENAI_COMPAT,
26
26
  glm: [...OPENAI_COMPAT, "/api/paas/v4/chat/completions", "/api/paas/v4/models"],
@@ -0,0 +1,52 @@
1
+ function csvCell(value) {
2
+ const text = value == null ? '' : String(value)
3
+ return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text
4
+ }
5
+
6
+ export function toCsv(rows) {
7
+ const list = Array.isArray(rows) ? rows : []
8
+ if (!list.length) return ''
9
+ const headers = Object.keys(list[0])
10
+ const lines = [headers.map(csvCell).join(',')]
11
+ for (const row of list) lines.push(headers.map((key) => csvCell(row[key])).join(','))
12
+ return lines.join('\n') + '\n'
13
+ }
14
+
15
+ export function buildUsageRows(accounts, now = Date.now()) {
16
+ const current = Number(now) || Date.now()
17
+ return (Array.isArray(accounts) ? accounts : []).map((account) => ({
18
+ ref: account.ref || '',
19
+ provider: account.provider || '',
20
+ index: account.index ?? '',
21
+ label: account.label || '',
22
+ plan: account.plan || account.paidTierName || '',
23
+ configured: Boolean(account.hasToken || account.configured),
24
+ usagePercent: account.usagePercent ?? '',
25
+ remainingPercent: account.usagePercent != null ? Math.max(0, 100 - Number(account.usagePercent)) : '',
26
+ quotaSource: (account.quota && account.quota.source) || 'unknown',
27
+ usageAgeMs: account.usageAt ? Math.max(0, current - Number(account.usageAt)) : '',
28
+ resetAt: (account.quota && account.quota.resetAt) || '',
29
+ cooldownUntil: account.cooldownUntil || '',
30
+ quarantineUntil: account.quarantineUntil || '',
31
+ quarantineReason: account.quarantineReason || '',
32
+ healthScore: account.healthScore ?? '',
33
+ monthlyCost: account.monthlyCost ?? '',
34
+ }))
35
+ }
36
+
37
+ export function buildCallRows(calls) {
38
+ return (Array.isArray(calls) ? calls : []).map((call) => ({
39
+ at: call.at || call.timestamp || '',
40
+ provider: call.provider || '',
41
+ ref: call.ref || '',
42
+ model: call.model || '',
43
+ path: call.path || '',
44
+ ok: call.ok !== false,
45
+ status: call.status ?? '',
46
+ inputTokens: call.inputTokens ?? '',
47
+ outputTokens: call.outputTokens ?? '',
48
+ cacheReadTokens: call.cacheReadTokens ?? '',
49
+ durationMs: call.durationMs ?? '',
50
+ error: call.error || '',
51
+ }))
52
+ }