@goodandready/dsh-subscriptions 0.6.91 → 0.7.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.
@@ -36,6 +36,7 @@ export function createAdapterManager({
36
36
  }
37
37
 
38
38
  const adapter = new SubscriptionAdapter({
39
+ live: () => live(),
39
40
  listAccounts: (provider) => store.listAccounts(provider),
40
41
  loadBlob: (ref) => store.loadBlob(ref),
41
42
  ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
@@ -46,6 +47,8 @@ export function createAdapterManager({
46
47
  drainSafetyPercent: () => live().drainSafetyPercent,
47
48
  switchAtRemaining: () => live().switchAtRemaining,
48
49
  rotationStrategy: () => live().rotationStrategy,
50
+ rateLimitWait: () => live().rateLimitWait,
51
+ rateLimitMaxWaitMs: () => live().rateLimitMaxWaitMs,
49
52
  enableSoftFailureSentinel: () => live().enableSoftFailureSentinel,
50
53
  rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
51
54
  rememberQuarantine: (ref, reason, until, attempts) => store.rememberQuarantine(ref, reason, until, attempts),
package/lib/adapter.js CHANGED
@@ -3,6 +3,8 @@ import { inferModelCapabilities, isDeprecatedId } from './models-catalog.js'
3
3
  import { fetchWithTimeout } from "./http.js"
4
4
  import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
5
5
  import { displayName } from './refs.js'
6
+ import { applyModelSettings, bestAccountPlan } from './model-settings.js'
7
+ import { getSpeedMode } from './speed-mode.js'
6
8
  import { getVendor } from './vendors/index.js'
7
9
  import { modelCatalog } from './messages.js'
8
10
  import { streamWithRotation } from './stream-rotate.js'
@@ -21,6 +23,9 @@ function asLlmError(err) {
21
23
  try {
22
24
  const out = new LlmError(message, code)
23
25
  if (err && err.validationUrl) out.validationUrl = err.validationUrl
26
+ if (err && err.retryAfterMs != null) out.retryAfterMs = err.retryAfterMs
27
+ if (err && err.resetAt != null) out.resetAt = err.resetAt
28
+ if (err && err.retryAfter != null) out.retryAfter = err.retryAfter
24
29
  return out
25
30
  } catch {
26
31
  return err
@@ -86,7 +91,7 @@ export class SubscriptionAdapter extends LlmAdapter {
86
91
  capabilities: Array.isArray(m.capabilities) && m.capabilities.length ? m.capabilities : caps.capabilities,
87
92
  }
88
93
  })
89
- return models
94
+ return applyModelSettings(provider, models, this.deps.live ? this.deps.live() : {}, bestAccountPlan(accounts))
90
95
  }
91
96
 
92
97
  imageRequestPricing() {
@@ -112,7 +117,7 @@ export class SubscriptionAdapter extends LlmAdapter {
112
117
  ...(reasoningMeta ? { reasoning: reasoningMeta } : {}),
113
118
  }
114
119
  }
115
- return { provider, id: model, name: model, ...(reasoningMeta ? { reasoning: reasoningMeta } : {}) }
120
+ return { provider, id: model, name: (found && found.name) || model, ...(reasoningMeta ? { reasoning: reasoningMeta } : {}), ...(found && found.contextWindow ? { context: { contextWindow: found.contextWindow } } : {}) }
116
121
  }
117
122
 
118
123
  async prepareCall(provider, model, signal) {
@@ -153,6 +158,7 @@ export class SubscriptionAdapter extends LlmAdapter {
153
158
  const decisionTrace = {}
154
159
  const streamOptions = {
155
160
  ...options,
161
+ ...(provider === 'codex' && getSpeedMode(sessionId) === 'fast' ? { service_tier: 'priority' } : {}),
156
162
  ...(effectiveReasoning.effort ? { reasoningEffort: effectiveReasoning.effort } : {}),
157
163
  ...(effectiveReasoning.budgetTokens ? { thinkingBudget: effectiveReasoning.budgetTokens } : {}),
158
164
  _decisionTrace: decisionTrace,
@@ -161,6 +167,8 @@ export class SubscriptionAdapter extends LlmAdapter {
161
167
  drainSafetyPercent: effectiveDrainSafetyPercent,
162
168
  rotationStrategy: effectiveRotationStrategy,
163
169
  enableSoftFailureSentinel: effectiveSoftFailureSentinel,
170
+ rateLimitWait: typeof deps.rateLimitWait === 'function' ? deps.rateLimitWait() : deps.rateLimitWait,
171
+ rateLimitMaxWaitMs: typeof deps.rateLimitMaxWaitMs === 'function' ? deps.rateLimitMaxWaitMs() : deps.rateLimitMaxWaitMs,
164
172
  }
165
173
  for await (const event of streamWithRotation({
166
174
  accounts: await deps.listAccounts(provider),
package/lib/client.js CHANGED
@@ -106,6 +106,10 @@ window.__ModuleLoader__.load({
106
106
  "hideDeprecated": "Hide deprecated models",
107
107
  "hideDeprecatedHint": "Hide older or superseded model variants from model dropdowns.",
108
108
  "composerQuota": "Composer quota indicator",
109
+ "composerQuotaDisplay": "Status-bar quota scope",
110
+ "composerQuotaDisplayHint": "Show quota for the provider of the current model, or hide the status-bar pill.",
111
+ "composerQuotaDisplayCurrent": "Current model",
112
+ "composerQuotaDisplayHidden": "Hidden",
109
113
  "composerQuotaHint": "Display subscription usage indicator directly in the composer toolbar.",
110
114
  "composerQuotaOff": "Disabled",
111
115
  "composerQuotaPercent": "Percentage",
@@ -255,11 +259,23 @@ window.__ModuleLoader__.load({
255
259
  "verifyLink": "Open verification link",
256
260
  "verifySuffix": " then reconnect.",
257
261
  "addAccount": "+ Add account",
262
+ "slotAdvanced": "Advanced slot settings",
263
+ "providerGallery": "Add provider",
264
+ "providerGalleryHint": "Unconnected providers are hidden from the main list to keep the page focused.",
265
+ "vipOnly": "VIP only",
266
+ "vipOnlyHint": "Reserve this account for VIP-tagged requests.",
267
+ "isPro": "Pro",
268
+ "isProHint": "Treat this account as Pro for weighted priority.",
269
+ "tags": "Tags",
270
+ "tagsPlaceholder": "team, prod",
271
+ "weight": "Weight",
272
+ "maxQueueSizeSlot": "Queue",
258
273
  "save": "Save",
259
274
  "saved": "Saved",
260
275
  "accountLabel": "Account",
261
276
  "plan": "Plan",
262
277
  "storedAs": "Stored as",
278
+ "slashFast": "Toggle Codex Speed (fast/standard)",
263
279
  "slashLogin": "Login to a subscription provider",
264
280
  "slashLogout": "Log out a subscription provider",
265
281
  "forecast": "≈",
@@ -404,6 +420,10 @@ window.__ModuleLoader__.load({
404
420
  "hideDeprecated": "隐藏已废弃的旧模型",
405
421
  "hideDeprecatedHint": "在模型下拉列表中隐藏早期或已被替代的旧版模型。",
406
422
  "composerQuota": "输入框额度指示器",
423
+ "composerQuotaDisplay": "状态栏额度范围",
424
+ "composerQuotaDisplayHint": "显示当前模型所属服务商的额度,或隐藏状态栏指示器。",
425
+ "composerQuotaDisplayCurrent": "当前模型",
426
+ "composerQuotaDisplayHidden": "隐藏",
407
427
  "composerQuotaHint": "在聊天输入框工具栏直接显示当前订阅额度指示器。",
408
428
  "composerQuotaOff": "已禁用",
409
429
  "composerQuotaPercent": "百分比显示",
@@ -553,11 +573,23 @@ window.__ModuleLoader__.load({
553
573
  "verifyLink": "打开验证链接",
554
574
  "verifySuffix": ",完成后点击重新连接。",
555
575
  "addAccount": "+ 添加账户",
576
+ "slotAdvanced": "账户高级设置",
577
+ "providerGallery": "添加服务商",
578
+ "providerGalleryHint": "未连接的服务商默认隐藏在列表中,保持页面聚焦。",
579
+ "vipOnly": "仅 VIP",
580
+ "vipOnlyHint": "将此账户保留给 VIP 请求。",
581
+ "isPro": "Pro",
582
+ "isProHint": "按 Pro 账户参与加权优先级。",
583
+ "tags": "标签",
584
+ "tagsPlaceholder": "team, prod",
585
+ "weight": "权重",
586
+ "maxQueueSizeSlot": "队列",
556
587
  "save": "保存",
557
588
  "saved": "已保存",
558
589
  "accountLabel": "账户",
559
590
  "plan": "套餐方案",
560
591
  "storedAs": "存储标识",
592
+ "slashFast": "切换 Codex 加速模式(快速/标准)",
561
593
  "slashLogin": "登录订阅服务商",
562
594
  "slashLogout": "登出订阅服务商",
563
595
  "forecast": "≈",
@@ -1785,6 +1817,18 @@ window.__ModuleLoader__.load({
1785
1817
  ),
1786
1818
  h('details', { className: 'dsub-group', open: true },
1787
1819
  h('summary', { className: 'dsub-groupHead' }, t('groupRotation')),
1820
+ h('div', { className: 'dsub-row' },
1821
+ h('span', { className: 'dsub-h' }, t('composerQuotaDisplay')),
1822
+ h('select', {
1823
+ className: 'dsub-select',
1824
+ value: draft.composerQuotaDisplay || 'current',
1825
+ onChange: (e) => setDraft((d) => Object.assign({}, d, { composerQuotaDisplay: e.target.value })),
1826
+ },
1827
+ h('option', { value: 'current' }, t('composerQuotaDisplayCurrent')),
1828
+ h('option', { value: 'hidden' }, t('composerQuotaDisplayHidden')),
1829
+ ),
1830
+ ),
1831
+ h('div', { className: 'dsub-sub' }, t('composerQuotaDisplayHint')),
1788
1832
  h('div', { className: 'dsub-row' },
1789
1833
  h('span', { className: 'dsub-h' }, t('composerQuota')),
1790
1834
  h('select', {
@@ -2347,7 +2391,17 @@ window.__ModuleLoader__.load({
2347
2391
  )
2348
2392
  ) : null,
2349
2393
  ),
2350
- names.map((prov) => {
2394
+ (function(){
2395
+ const configuredSlot = (slot) => {
2396
+ const account = accountOf(slot.provider, slot.index)
2397
+ return !!((account && (account.configured || account.hasToken))
2398
+ || slot.label || slot.proxyUrl || slot.expiresAt
2399
+ || (Array.isArray(slot.tags) && slot.tags.length) || slot.vipOnly || slot.isPro || slot.weight)
2400
+ }
2401
+ const visibleNames = names.filter((prov) => slots.some((slot) => slot.provider === prov.id && configuredSlot(slot)))
2402
+ const hiddenNames = names.filter((prov) => visibleNames.indexOf(prov) < 0)
2403
+ return h(React.Fragment, null,
2404
+ visibleNames.map((prov) => {
2351
2405
  const rows = slots
2352
2406
  .map((slot, i) => ({ slot: slot, i: i }))
2353
2407
  .filter((row) => row.slot.provider === prov.id)
@@ -2637,6 +2691,59 @@ window.__ModuleLoader__.load({
2637
2691
  },
2638
2692
  })
2639
2693
  ),
2694
+ h('details', { className: 'dsub-details' },
2695
+ h('summary', { className: 'dsub-summary' }, t('slotAdvanced')),
2696
+ h('div', { className: 'dsub-row' },
2697
+ h('label', { className: 'dsub-ref-tag', title: t('vipOnlyHint') },
2698
+ h('input', {
2699
+ type: 'checkbox',
2700
+ checked: !!row.slot.vipOnly,
2701
+ onChange: (e) => updateSlot(row.slot.provider, row.slot.index, { vipOnly: e.target.checked }),
2702
+ }),
2703
+ ' ' + t('vipOnly')
2704
+ ),
2705
+ h('label', { className: 'dsub-ref-tag', title: t('isProHint') },
2706
+ h('input', {
2707
+ type: 'checkbox',
2708
+ checked: !!row.slot.isPro,
2709
+ onChange: (e) => updateSlot(row.slot.provider, row.slot.index, { isPro: e.target.checked }),
2710
+ }),
2711
+ ' ' + t('isPro')
2712
+ ),
2713
+ ),
2714
+ h('div', { className: 'dsub-row' },
2715
+ h('span', { className: 'dsub-ref-tag' }, t('tags')),
2716
+ h('input', {
2717
+ className: 'dsub-inp',
2718
+ style: { width: '160px', minWidth: '160px' },
2719
+ value: (Array.isArray(row.slot.tags) ? row.slot.tags : []).join(','),
2720
+ placeholder: t('tagsPlaceholder'),
2721
+ onChange: (e) => updateSlot(row.slot.provider, row.slot.index, {
2722
+ tags: e.target.value.split(',').map((value) => value.trim()).filter(Boolean),
2723
+ }),
2724
+ }),
2725
+ h('span', { className: 'dsub-ref-tag' }, t('weight')),
2726
+ h('input', {
2727
+ type: 'number', min: 0, max: 100,
2728
+ style: { width: '52px', padding: '1px 4px', fontSize: '11px', textAlign: 'center' },
2729
+ value: row.slot.weight !== undefined ? row.slot.weight : 0,
2730
+ onChange: (e) => {
2731
+ const value = parseInt(e.target.value, 10)
2732
+ updateSlot(row.slot.provider, row.slot.index, { weight: isNaN(value) ? 0 : Math.max(0, value) })
2733
+ },
2734
+ }),
2735
+ h('span', { className: 'dsub-ref-tag' }, t('maxQueueSizeSlot')),
2736
+ h('input', {
2737
+ type: 'number', min: 0, max: 1000,
2738
+ style: { width: '58px', padding: '1px 4px', fontSize: '11px', textAlign: 'center' },
2739
+ value: row.slot.maxQueueSize !== undefined ? row.slot.maxQueueSize : 0,
2740
+ onChange: (e) => {
2741
+ const value = parseInt(e.target.value, 10)
2742
+ updateSlot(row.slot.provider, row.slot.index, { maxQueueSize: isNaN(value) ? 0 : Math.max(0, value) })
2743
+ },
2744
+ }),
2745
+ ),
2746
+ ),
2640
2747
  ),
2641
2748
  (function(){
2642
2749
  var r = proxyRes[key]
@@ -2917,6 +3024,20 @@ window.__ModuleLoader__.load({
2917
3024
  }, t('addAccount')),
2918
3025
  )
2919
3026
  }),
3027
+ hiddenNames.length ? h('details', { className: 'dsub-group', open: visibleNames.length === 0 },
3028
+ h('summary', { className: 'dsub-groupHead' }, '➕ ' + t('providerGallery')),
3029
+ h('div', { className: 'dsub-sub' }, t('providerGalleryHint')),
3030
+ h('div', { className: 'dsub-model-grid' },
3031
+ hiddenNames.map((prov) => h('button', {
3032
+ key: prov.id,
3033
+ type: 'button',
3034
+ className: 'dsub-mini',
3035
+ onClick: () => addSlot(prov.id),
3036
+ }, prov.name))
3037
+ )
3038
+ ) : null
3039
+ )
3040
+ })(),
2920
3041
  h('div', { className: 'dsub-foot' },
2921
3042
  h('button', {
2922
3043
  type: 'button', className: 'dsub-save',
@@ -3103,10 +3224,28 @@ window.__ModuleLoader__.load({
3103
3224
  labels: (data && data.labels) || {},
3104
3225
  expiryNotifyDays: (data && data.expiryNotifyDays) || 7,
3105
3226
  composerQuota: (data && data.composerQuota) || 'off',
3227
+ composerQuotaDisplay: (data && data.composerQuotaDisplay) || 'current',
3106
3228
  active: (data && data.active) || null,
3107
3229
  error: null,
3108
3230
  }
3109
- } catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, fastMode: false, composerQuota: 'off', active: null, error: 'status unavailable' } }
3231
+ } catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, fastMode: false, composerQuota: 'off', composerQuotaDisplay: 'current', active: null, error: 'status unavailable' } }
3232
+ }
3233
+
3234
+ function currentModelProvider(props) {
3235
+ let session = null
3236
+ try {
3237
+ const ctx = typeof window !== 'undefined' ? window.__dshCtx : null
3238
+ session = (props && (props.session || props.conversation))
3239
+ || (ctx && ctx.sessions && (ctx.sessions.current || ctx.sessions.active
3240
+ || (typeof ctx.sessions.getCurrent === 'function' && ctx.sessions.getCurrent())))
3241
+ || null
3242
+ } catch { /* sessions service is optional */ }
3243
+ const model = session && (session.model || session.modelId
3244
+ || (session.header && session.header.model) || (session.state && session.state.model))
3245
+ if (!model) return null
3246
+ if (typeof model === 'object') return model.provider || null
3247
+ const text = String(model)
3248
+ return text.includes('/') ? text.split('/')[0] : null
3110
3249
  }
3111
3250
 
3112
3251
  function ComposerQuota(props) {
@@ -3120,11 +3259,22 @@ window.__ModuleLoader__.load({
3120
3259
  fetch('/dsh-subscriptions/analytics', { cache: 'no-store' })
3121
3260
  .then((res) => res.json())
3122
3261
  .catch(() => ({})),
3123
- ]).then(([r, analyticsPayload]) => {
3262
+ ]).then(async ([r, analyticsPayload]) => {
3124
3263
  if (!alive) return
3125
3264
  const mode = r.composerQuota || 'off'
3126
- const a = r.active
3127
- if (mode !== 'off' && a && a.provider && a.provider !== 'ollama' && a.usagePercent != null) {
3265
+ const current = currentModelProvider(props)
3266
+ let a = r.active
3267
+ if (current && (!a || a.provider !== current)) {
3268
+ const cfg = await fetch('/dsh-subscriptions/config', { cache: 'no-store' })
3269
+ .then((res) => res.json())
3270
+ .catch(() => null)
3271
+ const rows = (cfg && cfg.accounts) || []
3272
+ a = rows.find((row) => row && row.provider === current && row.usagePercent != null)
3273
+ || rows.find((row) => row && row.provider === current)
3274
+ || null
3275
+ }
3276
+ const hidden = r.composerQuotaDisplay === 'hidden'
3277
+ if (mode !== 'off' && !hidden && a && a.provider && a.provider !== 'ollama' && a.usagePercent != null) {
3128
3278
  const remaining = Math.max(0, 100 - a.usagePercent)
3129
3279
  const forecast = (analyticsPayload && analyticsPayload.analytics && analyticsPayload.analytics.forecast) || []
3130
3280
  const row = forecast.find((f) => f.provider === a.provider && (!f.ref || !a.ref || f.ref === a.ref))
@@ -3134,9 +3284,9 @@ window.__ModuleLoader__.load({
3134
3284
  ? { status: 'idle' }
3135
3285
  : { status: 'forecast', runwaySeconds: Math.max(0, Number(row.hoursToExhaustion) * 3600) }
3136
3286
  }
3137
- setState({ mode, active: a, remaining, est })
3287
+ setState({ mode, active: a, remaining, est, hidden })
3138
3288
  } else {
3139
- setState({ mode, active: a, remaining: null, est: null })
3289
+ setState({ mode, active: a, remaining: null, est: null, hidden })
3140
3290
  }
3141
3291
  }).catch(() => {})
3142
3292
  }
@@ -3144,7 +3294,7 @@ window.__ModuleLoader__.load({
3144
3294
  const id = setInterval(pull, 60 * 1000)
3145
3295
  return () => { alive = false; clearInterval(id) }
3146
3296
  }, [])
3147
- if (state.mode === 'off' || !state.active || state.remaining == null) return null
3297
+ if (state.mode === 'off' || state.hidden || !state.active || state.remaining == null) return null
3148
3298
  const cls = state.remaining <= 10 ? ' dsub-cqBarFull' : (state.remaining <= 30 ? ' dsub-cqBarWarn' : '')
3149
3299
  var value = null
3150
3300
  if (state.mode === 'percent') {
@@ -3555,6 +3705,7 @@ window.__ModuleLoader__.load({
3555
3705
  const line = (line) => line.trim()
3556
3706
  const sources = [
3557
3707
  { name: 'login', description: t('slashLogin') },
3708
+ { name: 'fast', description: t('slashFast') },
3558
3709
  { name: 'logout', description: t('slashLogout') },
3559
3710
  ]
3560
3711
  const disposers = sources.map((src) =>
@@ -3579,6 +3730,17 @@ window.__ModuleLoader__.load({
3579
3730
  return refreshLoggedIn().then((logged) =>
3580
3731
  ({ text: 'Connected: ' + (logged.length ? logged.join(', ') : 'none') }))
3581
3732
  }
3733
+ if (src.name === 'fast') {
3734
+ const mode = /\bstandard\b|\boff\b/.test(t2) ? 'standard' : 'fast'
3735
+ const sid = (_session && (_session.id || _session.sessionId || (_session.header && _session.header.id))) || 'default'
3736
+ return fetch('/dsh-subscriptions/speed', {
3737
+ method: 'POST',
3738
+ headers: { 'Content-Type': 'application/json' },
3739
+ body: JSON.stringify({ sessionId: sid, mode }),
3740
+ }).then((r) => r.json()).then((d) => ({
3741
+ text: (d && d.mode === 'fast' ? 'Codex Speed: fast' : 'Codex Speed: standard'),
3742
+ })).catch((e) => ({ text: 'Codex Speed: ' + String(e && e.message || e) }))
3743
+ }
3582
3744
  run(src.name, t2)
3583
3745
  return Promise.resolve('handled')
3584
3746
  },
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ export const CONFLICTING_PLUGINS = Object.freeze([
6
+ 'dsh-plugin-subscriptions',
7
+ 'dsh-super-subscriptions',
8
+ 'dsh-agy-link',
9
+ 'dsh-codex-connect',
10
+ 'dsh-key-rotation',
11
+ '@goodandready/dsh-key-rotation',
12
+ ])
13
+
14
+ let activeConflicts = []
15
+
16
+ export function detectConflicts({ dependencies = [], loaded = [] } = {}) {
17
+ const names = new Set([...(dependencies || []), ...(loaded || [])].filter(Boolean).map(String))
18
+ return CONFLICTING_PLUGINS.filter((name) => names.has(name))
19
+ }
20
+
21
+ export function setActiveConflicts(conflicts) {
22
+ activeConflicts = Array.isArray(conflicts) ? conflicts.slice() : []
23
+ }
24
+
25
+ export function getActiveConflicts() {
26
+ return activeConflicts.slice()
27
+ }
28
+
29
+ export function readProfilePluginNames(profileDir = process.env.DSH_PROFILE_DIR) {
30
+ const candidates = []
31
+ if (profileDir) candidates.push(profileDir)
32
+ try { candidates.push(process.cwd()) } catch { /* ignore cwd errors */ }
33
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
34
+ const profile = process.env.DSH_PROFILE
35
+ if (profile) candidates.push(path.join(home, 'profiles', profile))
36
+ for (const dir of candidates) {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'))
39
+ const deps = Object.keys(pkg.dependencies || {})
40
+ const bundles = (pkg.dsh && pkg.dsh.profile && pkg.dsh.profile.bundles) || []
41
+ return [...deps, ...bundles].filter(Boolean)
42
+ } catch { /* try next candidate */ }
43
+ }
44
+ return []
45
+ }
@@ -78,6 +78,16 @@ export const Config = z.object({
78
78
  .description('#91 Ollama model used for the fallback (for example qwen2.5-coder). Empty = first model from /api/tags.'),
79
79
  hideDeprecatedModels: z.boolean().volatile().default(false)
80
80
  .description('#94 Hide test/preview/beta/legacy model ids from the native model picker.'),
81
+ modelDisplayNames: z.array(z.object({ provider: z.string(), id: z.string(), name: z.string() })).volatile().default([])
82
+ .description('#681 Per-model display names shown in the native model picker.'),
83
+ hiddenModels: z.array(z.object({ provider: z.string(), id: z.string() })).volatile().default([])
84
+ .description('#681 Per-model visibility switches for the native model picker.'),
85
+ modelContextWindows: z.array(z.object({ provider: z.string(), id: z.string(), contextWindow: z.number() })).volatile().default([])
86
+ .description('#681 Per-model context-window override.'),
87
+ modelReasoningDefaults: z.array(z.object({ provider: z.string(), id: z.string(), effort: z.string() })).volatile().default([])
88
+ .description('#681 Per-model default reasoning effort.'),
89
+ modelPlans: z.array(z.object({ provider: z.string(), id: z.string(), minPlan: z.string(), plan: z.string() })).volatile().default([])
90
+ .description('#690 Minimum subscription plan per model, shown in the picker metadata.'),
81
91
  codexClientId: z.string().volatile().default(''),
82
92
  codexVerbosity: z.string().volatile().default('')
83
93
  .description('#93 Response verbosity for Codex reasoning models: low, medium or high. Empty = protocol default.'),
@@ -85,6 +95,12 @@ export const Config = z.object({
85
95
  .description('#92 Fast Mode for Codex: sends service_tier priority (1.5x speed billing tier) with every request.'),
86
96
  composerQuota: z.string().volatile().default('off')
87
97
  .description('#84 Composer quota indicator mode: off, percent, bar or forecast (predictive runway from a sliding window).'),
98
+ composerQuotaDisplay: z.string().volatile().default('current')
99
+ .description('#682 Status-bar quota scope: current (model provider) or hidden.'),
100
+ rateLimitWait: z.boolean().volatile().default(true)
101
+ .description('#684 Report the disclosed reset to dsh-llm-retry instead of parking the account on a guess.'),
102
+ rateLimitMaxWaitMs: z.number().volatile().default(21600000)
103
+ .description('#684 Maximum reset wait reported to dsh-llm-retry (default 6h).'),
88
104
  codexRedirectUri: z.string().volatile().default(''),
89
105
  codexBaseUrl: z.string().volatile().default(''),
90
106
  codexClientVersion: z.string().volatile().default('')
package/lib/index.js CHANGED
@@ -14,6 +14,8 @@ import { parseBlob } from './blob.js'
14
14
  import { createVendorFromProfile, validateProfiles } from './vendor-factory.js'
15
15
  import { registerCustomProviderIds, registerDisplayName } from './refs.js'
16
16
  import { createSubscriptionsService } from './subscriptions.js'
17
+ import { registerSubscriptionTools } from './native-tools.js'
18
+ import { detectConflicts, readProfilePluginNames, setActiveConflicts } from './coexistence.js'
17
19
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
18
20
  import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
19
21
  import { createResetCreditService } from './reset-credits.js'
@@ -39,7 +41,7 @@ export { pushToWebdav, pullFromWebdav, pushToGist, pullFromGist, buildS3SigV4Hea
39
41
  import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from './vault-sync.js'
40
42
 
41
43
  export const name = '@goodandready/dsh-subscriptions'
42
- export const inject = ['llm', 'credentials', 'webServer', 'settings']
44
+ export const inject = ['llm', 'credentials', 'webServer', 'settings', 'tools']
43
45
 
44
46
  const NS = 'dsh-subscriptions'
45
47
 
@@ -75,6 +77,11 @@ const OK_HTML = `<!doctype html>
75
77
 
76
78
  export function apply(ctx, rawConfig) {
77
79
  const logger = typeof ctx.logger === 'function' ? ctx.logger('subscriptions') : (ctx.logger || undefined)
80
+ const conflicts = detectConflicts({ dependencies: readProfilePluginNames() })
81
+ setActiveConflicts(conflicts)
82
+ if (conflicts.length && logger && logger.warn) {
83
+ logger.warn('[dsh-subscriptions] conflicting subscription plugins detected: ' + conflicts.join(', ') + '; keep one provider owner')
84
+ }
78
85
 
79
86
  // rawConfig still holds Volatile boxes, so unwrap before validating.
80
87
  const resolveEntry = () => {
@@ -377,6 +384,10 @@ export function apply(ctx, rawConfig) {
377
384
  })
378
385
  },
379
386
  }), 'dsh-subscriptions: image generation service')
387
+ ctx.effect(() => registerSubscriptionTools(ctx, {
388
+ subscriptions,
389
+ images: () => ctx.subscriptionImages,
390
+ }), 'dsh-subscriptions: native tools')
380
391
 
381
392
  const pending = new Map()
382
393
 
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,7 @@ 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'),
258
262
  active,
259
263
  })
260
264
  }),
@@ -297,7 +301,14 @@ export function registerStatusRoutes(ctx, state) {
297
301
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
298
302
  return
299
303
  }
300
- writeJson(res, 200, { ok: true, report: await diagnosticsReport() })
304
+ const accounts = await accountsView()
305
+ const last = history.recent(1)[0]
306
+ writeJson(res, 200, {
307
+ ok: true,
308
+ report: await diagnosticsReport(),
309
+ conflicts: getActiveConflicts(),
310
+ route: buildRouteReport(last, accounts),
311
+ })
301
312
  }),
302
313
  }), 'dsh-subscriptions: /diagnostics')
303
314
 
@@ -436,6 +447,45 @@ export function registerStatusRoutes(ctx, state) {
436
447
  }),
437
448
  }), 'dsh-subscriptions: /history')
438
449
 
450
+ // #687: quota snapshot and per-call ledger export for DSH quota plugins.
451
+ ctx.effect(() => ctx.webServer.register({
452
+ kind: 'exact',
453
+ path: '/dsh-subscriptions/usage',
454
+ handler: safeJsonHandler(async (req, res) => {
455
+ if (req.method !== 'GET') {
456
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
457
+ return
458
+ }
459
+ if (!isTrustedSettingsRequest(req, ctx)) {
460
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
461
+ return
462
+ }
463
+ const q = queryOf(req)
464
+ const type = q.get('type') === 'calls' ? 'calls' : 'accounts'
465
+ const format = String(q.get('format') || 'json').toLowerCase()
466
+ const now = Date.now()
467
+ const accounts = await accountsView()
468
+ const calls = history.recent(500)
469
+ const rows = type === 'calls' ? buildCallRows(calls) : buildUsageRows(accounts, now)
470
+ if (format === 'csv') {
471
+ res.writeHead(200, {
472
+ 'Content-Type': 'text/csv; charset=utf-8',
473
+ 'Content-Disposition': 'attachment; filename="dsh-subscriptions-' + type + '.csv"',
474
+ 'Cache-Control': 'no-store',
475
+ })
476
+ res.end(toCsv(rows))
477
+ return
478
+ }
479
+ writeJson(res, 200, {
480
+ ok: true,
481
+ generatedAt: now,
482
+ type,
483
+ rows,
484
+ summary: { accounts: Array.isArray(accounts) ? accounts.length : 0, calls: Array.isArray(calls) ? calls.length : 0 },
485
+ })
486
+ }),
487
+ }), 'dsh-subscriptions: /usage')
488
+
439
489
  // Telemetry summary route for UI stat cards (requests, latency, success rate)
440
490
  ctx.effect(() => ctx.webServer.register({
441
491
  kind: 'exact',
@@ -716,6 +766,41 @@ export function registerStatusRoutes(ctx, state) {
716
766
  }),
717
767
  }), 'dsh-subscriptions: /queue')
718
768
 
769
+ // #681: per-session Codex Speed/Fast toggle.
770
+ ctx.effect(() => ctx.webServer.register({
771
+ kind: 'exact',
772
+ path: '/dsh-subscriptions/speed',
773
+ handler: safeJsonHandler(async (req, res) => {
774
+ if (!isTrustedSettingsRequest(req, ctx)) {
775
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
776
+ return
777
+ }
778
+ if (req.method === 'GET') {
779
+ const sid = queryOf(req).get('sessionId') || 'default'
780
+ writeJson(res, 200, { ok: true, sessionId: sid, mode: getSpeedMode(sid) || 'standard' })
781
+ return
782
+ }
783
+ if (req.method !== 'POST') {
784
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST only' } })
785
+ return
786
+ }
787
+ let body = {}
788
+ try { body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}') } catch {
789
+ writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
790
+ return
791
+ }
792
+ const sid = String(body.sessionId || 'default').slice(0, 200)
793
+ const mode = String(body.mode || '').toLowerCase()
794
+ if (mode !== 'fast' && mode !== 'standard' && mode !== 'off') {
795
+ writeJson(res, 400, { ok: false, error: { code: 'mode', message: 'mode must be fast or standard' } })
796
+ return
797
+ }
798
+ if (mode === 'fast') setSpeedMode(sid, 'fast')
799
+ else clearSpeedMode(sid)
800
+ writeJson(res, 200, { ok: true, sessionId: sid, mode: mode === 'fast' ? 'fast' : 'standard' })
801
+ }),
802
+ }), 'dsh-subscriptions: /speed')
803
+
719
804
  // #519: Interactive Diagnostic Wizard for accounts and connectivity
720
805
  ctx.effect(() => ctx.webServer.register({
721
806
  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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.91",
3
+ "version": "0.7.2",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",