@goodandready/dsh-subscriptions 0.6.46 → 0.6.50

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/accounts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { calculateBurnRatePerHour } from "./forecast.js"
1
2
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
2
3
  import { homedir } from "node:os"
3
4
  import { join } from "node:path"
@@ -105,10 +106,12 @@ export function vendorConfig(provider, cfg) {
105
106
  export function createAccountStore({ credentials, getConfig, fetchImpl, fetchForRef, onLimitNotice }) {
106
107
  const cooldowns = new Map()
107
108
  const quotas = new Map()
109
+ const quotaSamples = new Map()
108
110
  const quarantines = sharedQuarantines
109
111
  const writeQueues = new Map()
110
112
  const credentialVersions = new Map()
111
113
  const clearedRefs = new Set()
114
+ const latestBlobs = new Map()
112
115
  const refreshLocks = new Map()
113
116
  const refreshFailures = new Map()
114
117
  const usage = new Map()
@@ -144,6 +147,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
144
147
  async function saveBlob(ref, blob) {
145
148
  if (!ref || typeof ref !== "string") throw new Error("invalid ref for saveBlob")
146
149
  clearedRefs.delete(ref)
150
+ latestBlobs.set(ref, blob)
147
151
  const version = (credentialVersions.get(ref) || 0) + 1
148
152
  credentialVersions.set(ref, version)
149
153
 
@@ -154,8 +158,10 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
154
158
  }
155
159
  try {
156
160
  await credentials.set(credentialRef(ref), serializeBlob(blob))
157
- if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
161
+ if (clearedRefs.has(ref)) {
158
162
  try { await credentials.unset(credentialRef(ref)) } catch { /* best-effort */ }
163
+ } else if (credentialVersions.get(ref) > version && latestBlobs.has(ref)) {
164
+ try { await credentials.set(credentialRef(ref), serializeBlob(latestBlobs.get(ref))) } catch { /* best-effort */ }
159
165
  }
160
166
  } catch (err) {
161
167
  if (clearedRefs.has(ref) || credentialVersions.get(ref) !== version) {
@@ -174,6 +180,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
174
180
  async function clearRef(ref) {
175
181
  if (!ref || typeof ref !== "string") return
176
182
  clearedRefs.add(ref)
183
+ latestBlobs.delete(ref)
177
184
  const version = (credentialVersions.get(ref) || 0) + 1
178
185
  credentialVersions.set(ref, version)
179
186
  try {
@@ -181,7 +188,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
181
188
  } catch { /* best-effort unset */ }
182
189
  cooldowns.delete(ref)
183
190
  quotas.delete(ref)
191
+ quotaSamples.delete(ref)
184
192
  quarantines.delete(ref)
193
+ saveQuarantinesToFile(quarantines)
185
194
  writeQueues.delete(ref)
186
195
  for (const k of [...notifyThresholds.keys()]) { if (k.startsWith(ref + ':')) notifyThresholds.delete(k) }
187
196
  refreshFailures.delete(ref)
@@ -226,7 +235,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
226
235
  quarantineReason: quarantines.get(ref) ? quarantines.get(ref).reason : null,
227
236
  cooldownFamilies: cooldowns.get(ref) && cooldowns.get(ref).families ? cooldowns.get(ref).families : null,
228
237
  quota: quotas.get(ref) || null,
229
- pacePerHour: quotas.get(ref)?.pacePerHour || 0,
238
+ pacePerHour: quotas.get(ref)?.pacePerHour || calculateBurnRatePerHour(quotaSamples.get(ref) || [], Date.now()),
239
+ samples: quotaSamples.get(ref) || [],
230
240
  usage: windows.get(ref) || base.usage || null,
231
241
  health: health.get(ref) || null,
232
242
  healthScore: computeHealthScore(health.get(ref) || null),
@@ -245,11 +255,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
245
255
  const curConfig = getConfig()
246
256
  const slots = normalizeSlots(curConfig.slots).filter((s) => s.provider === provider)
247
257
  const out = []
248
- const now = Date.now()
249
258
  for (const slot of slots) {
250
259
  const info = await describeRef(slot.ref)
251
260
  const q = quarantines.get(slot.ref)
252
- const inQ = q && Number(q.until) > now
253
261
  out.push({
254
262
  ref: slot.ref,
255
263
  hasToken: !!info.configured,
@@ -259,8 +267,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
259
267
  quota: info.quota || null,
260
268
  healthScore: computeHealthScore(info.health),
261
269
  healthBadge: healthBadge(computeHealthScore(info.health)),
262
- quarantineUntil: inQ ? q.until : 0,
263
- quarantineReason: inQ ? q.reason : null,
270
+ quarantineUntil: q ? q.until : 0,
271
+ quarantineReason: q ? q.reason : null,
264
272
  label: slot.label || info.label,
265
273
  pacePerHour: info.pacePerHour || 0,
266
274
  })
@@ -390,6 +398,9 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
390
398
  if (currentRaw) currentBlob = parseBlob(currentRaw)
391
399
  } catch { /* best-effort */ }
392
400
  if (!currentBlob || clearedRefs.has(slot.ref)) return
401
+ if (latestBlobs.has(slot.ref)) {
402
+ currentBlob = { ...currentBlob, ...latestBlobs.get(slot.ref) }
403
+ }
393
404
  const prev = Array.isArray(currentBlob.usage) ? JSON.stringify(currentBlob.usage) : ''
394
405
  if (prev !== JSON.stringify(list)) {
395
406
  await saveBlob(slot.ref, { ...currentBlob, usage: list, usageAt: Date.now() })
@@ -439,7 +450,24 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
439
450
  if (prev && Array.isArray(prev.families)) for (const f of prev.families) merged.add(f)
440
451
  cooldowns.set(ref, { until: Math.max(Number(until) || 0, prev ? prev.until : 0), families: Array.from(merged) })
441
452
  },
442
- rememberQuota(ref, snap) { if (snap) quotas.set(ref, snap); else quotas.delete(ref) },
453
+ rememberQuota(ref, snap) {
454
+ if (snap) {
455
+ quotas.set(ref, snap)
456
+ if (snap.usedPercent != null) {
457
+ const now = snap.measuredAt || Date.now()
458
+ const samples = quotaSamples.get(ref) || []
459
+ const remainingPercent = snap.remainingPercent ?? (100 - Number(snap.usedPercent))
460
+ samples.push({ at: now, pct: remainingPercent })
461
+ while (samples.length > 20) samples.shift()
462
+ quotaSamples.set(ref, samples)
463
+ snap.samples = [...samples]
464
+ snap.pacePerHour = calculateBurnRatePerHour(samples, now)
465
+ }
466
+ } else {
467
+ quotas.delete(ref)
468
+ quotaSamples.delete(ref)
469
+ }
470
+ },
443
471
  shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
444
472
  getQuota(ref) { return quotas.get(ref) || null },
445
473
  rememberUsage(ref, percent) { usage.set(ref, percent) },
@@ -452,6 +480,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
452
480
  // successful request clears the cooldown and puts the account back in rotation
453
481
  cooldowns.delete(ref)
454
482
  quarantines.delete(ref)
483
+ saveQuarantinesToFile(quarantines)
455
484
  if (health.has(ref)) health.set(ref, null)
456
485
  },
457
486
  rememberQuarantine(ref, reason, until, attempts = 1) {
@@ -467,9 +496,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
467
496
  getQuarantine(ref, now = Date.now()) {
468
497
  const q = quarantines.get(ref)
469
498
  if (!q) return null
470
- if (Number(q.until) <= now) {
499
+ if (Number(q.until) <= Number(now)) {
471
500
  if (q.status === STATUS_PROBING) return q
472
- quarantines.delete(ref)
473
501
  return null
474
502
  }
475
503
  return q
@@ -41,9 +41,12 @@ export function createAdapterManager({
41
41
  ensureFresh: (provider, blob, ref) => store.ensureFresh(provider, blob, ref),
42
42
  vendorConfig: (provider) => vendorConfig(provider, live()),
43
43
  cooldownMs: () => live().cooldownMs,
44
+ autoPacing: () => live().autoPacing,
44
45
  switchAtRemaining: () => live().switchAtRemaining,
45
46
  rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
46
47
  rememberQuarantine: (ref, reason, until) => store.rememberQuarantine(ref, reason, until),
48
+ getQuarantine: (ref) => store.getQuarantine(ref),
49
+ probeWarmup: (ref, fn) => store.probeWarmup(ref, fn),
47
50
  rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
48
51
  getQuota: (ref) => store.getQuota(ref),
49
52
  refreshUsage: (provider) => store.refreshUsage(provider),
@@ -60,7 +63,7 @@ export function createAdapterManager({
60
63
  fetchForRef,
61
64
  hideDeprecatedModels: () => Boolean(live().hideDeprecatedModels),
62
65
  cascadingFallback: (...args) => cascadeFallbackStream(...args),
63
- ollamaFallback: typeof ollamaFallback === 'function' ? ollamaFallback : async function* ({ err }) { throw err },
66
+ ollamaFallback: typeof ollamaFallback === 'function' ? ollamaFallback : async function* ({ err }) { yield { type: 'finish', reason: { kind: 'error', failure: { code: err?.code || 'RATE_LIMIT', message: err?.message || 'all subscription accounts exhausted' } } } },
64
67
  })
65
68
 
66
69
  async function* cascadeFallbackStream({ options, provider, err, visited = new Set() }) {
package/lib/adapter.js CHANGED
@@ -118,16 +118,23 @@ export class SubscriptionAdapter extends LlmAdapter {
118
118
  if (p && typeof p.catch === 'function') p.catch(() => {})
119
119
  } catch { /* fire-and-forget background usage refresh */ }
120
120
  }
121
+ const effectiveAutoPacing = options.autoPacing !== undefined
122
+ ? options.autoPacing
123
+ : (typeof deps.autoPacing === 'function' ? deps.autoPacing() : (deps.autoPacing ?? true))
124
+ const streamOptions = { ...options, autoPacing: effectiveAutoPacing }
121
125
  for await (const event of streamWithRotation({
122
126
  accounts: await deps.listAccounts(provider),
123
127
  nowMs: () => Date.now(),
124
128
  cooldownMs: deps.cooldownMs(),
125
129
  switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
126
- options,
130
+ options: streamOptions,
127
131
  webhookUrl: typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl,
128
132
  fetchImpl: deps.fetchImpl || fetch,
129
133
  onCooldown: (account) => {
130
134
  if (typeof deps.rememberCooldown === 'function') deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
135
+ if (typeof deps.rememberQuarantine === 'function') {
136
+ deps.rememberQuarantine(account.ref, account.quarantineReason || 'RATE_LIMIT', account.quarantineUntil || account.cooldownUntil)
137
+ }
131
138
  if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
132
139
  },
133
140
  streamOnce: async function* (account, opts) {
@@ -135,6 +142,7 @@ export class SubscriptionAdapter extends LlmAdapter {
135
142
  let firstChunkTime = null
136
143
  let outputTokens = 0
137
144
  let inputTokens = 0
145
+ let reasoningTokens = 0
138
146
  let cacheReadTokens = 0
139
147
  let cacheWriteTokens = 0
140
148
  let approxChars = 0
@@ -188,6 +196,7 @@ export class SubscriptionAdapter extends LlmAdapter {
188
196
  tps,
189
197
  inputTokens: inputTokens || undefined,
190
198
  outputTokens: outputTokens || undefined,
199
+ reasoningTokens: reasoningTokens || undefined,
191
200
  cacheReadTokens: cacheReadTokens || undefined,
192
201
  cacheWriteTokens: cacheWriteTokens || undefined,
193
202
  kind: 'stream',
@@ -254,6 +263,24 @@ export class SubscriptionAdapter extends LlmAdapter {
254
263
  }
255
264
 
256
265
  try {
266
+ // Warmup probe check for accounts returning from quarantine (#350)
267
+ if (typeof deps.probeWarmup === "function") {
268
+ const qUntil = Number(account.quarantineUntil || 0)
269
+ if (qUntil > 0 && qUntil <= Date.now()) {
270
+ const probeResult = await deps.probeWarmup(account.ref, async () => {
271
+ if (vendor && typeof vendor.check === "function") {
272
+ const probeBlob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
273
+ await vendor.check(probeBlob, { config: deps.vendorConfig(provider), signal: opts && opts.signal })
274
+ }
275
+ })
276
+ if (probeResult && !probeResult.success) {
277
+ const err = probeResult.error || new Error("warmup probe failed")
278
+ err.code = "QUOTA"
279
+ throw err
280
+ }
281
+ }
282
+ }
283
+
257
284
  blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
258
285
  if (typeof deps.rememberRequest === 'function') deps.rememberRequest(account.ref)
259
286
  for await (const chunk of vendor.streamOnce({
@@ -280,6 +307,9 @@ export class SubscriptionAdapter extends LlmAdapter {
280
307
  if (u && typeof u.inputTokens === 'number') {
281
308
  inputTokens = u.inputTokens
282
309
  }
310
+ if (u && typeof u.reasoningTokens === 'number') {
311
+ reasoningTokens = u.reasoningTokens
312
+ }
283
313
  if (u && typeof u.cacheReadTokens === 'number') {
284
314
  cacheReadTokens = u.cacheReadTokens
285
315
  }
@@ -309,7 +339,19 @@ export class SubscriptionAdapter extends LlmAdapter {
309
339
  }
310
340
  throw err
311
341
  } finally {
312
- if (errorCaught) {
342
+ const isFinishError = finishReason && (
343
+ finishReason.kind === 'error' ||
344
+ finishReason === 'error' ||
345
+ (typeof finishReason === 'object' && Boolean(finishReason.failure))
346
+ )
347
+ if (errorCaught || isFinishError) {
348
+ if (!errorCaught && isFinishError) {
349
+ errorCaught = new Error(
350
+ typeof finishReason === 'object' && finishReason.failure
351
+ ? (finishReason.failure.message || finishReason.failure.code || 'stream finished with error')
352
+ : 'stream finished with error'
353
+ )
354
+ }
313
355
  finalizeOnce('error')
314
356
  } else if (opts && opts._runawayState && opts._runawayState.tripped) {
315
357
  finalizeOnce('runaway')
package/lib/client.js CHANGED
@@ -2077,23 +2077,23 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2077
2077
  // The card in the Plugins tab follows the bash/agent-loop/web-search cards:
2078
2078
  // titled header with description and chevron, collapsible, bordered.
2079
2079
  function PluginCard(props) {
2080
- const t = (props && props.t) || ((key) => key)
2080
+ const cardT = (props && props.t) || t
2081
2081
  const page = !!(props && props.view === 'page')
2082
2082
  const [open, setOpen] = React.useState(!!page)
2083
2083
  // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
2084
2084
  // padding, so the summary is a one-liner and the page drops our card chrome.
2085
2085
  if (props && props.view === 'summary') {
2086
- return React.createElement('div', { className: 'dsub-description' }, t('cardIntro'))
2086
+ return React.createElement('div', { className: 'dsub-description' }, cardT('cardIntro'))
2087
2087
  }
2088
- // Check host snapshot status (#408): loading or unavailable
2088
+ // Check host snapshot status (#408, #459): loading, unavailable, or error
2089
2089
  if (props && props.status === 'loading') {
2090
2090
  return React.createElement(page ? 'div' : 'li', {
2091
2091
  className: page ? 'dsub-page' : 'dsub-card',
2092
2092
  },
2093
2093
  React.createElement('div', { className: 'dsub-header', style: { cursor: 'default' } },
2094
2094
  React.createElement('div', { className: 'dsub-headText' },
2095
- React.createElement('div', { className: 'dsub-name' }, t('title')),
2096
- React.createElement('div', { className: 'dsub-description' }, t('loading') || t('settingsLoading') || 'Loading…'),
2095
+ React.createElement('div', { className: 'dsub-name' }, cardT('title')),
2096
+ React.createElement('div', { className: 'dsub-description' }, cardT('loading') || cardT('settingsLoading') || 'Loading…'),
2097
2097
  ),
2098
2098
  )
2099
2099
  )
@@ -2104,9 +2104,23 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2104
2104
  },
2105
2105
  React.createElement('div', { className: 'dsub-header', style: { cursor: 'default' } },
2106
2106
  React.createElement('div', { className: 'dsub-headText' },
2107
- React.createElement('div', { className: 'dsub-name' }, t('title')),
2107
+ React.createElement('div', { className: 'dsub-name' }, cardT('title')),
2108
2108
  React.createElement('div', { className: 'dsub-description', style: { color: 'var(--dsw-alias-state-warning-primary)' } },
2109
- t('unavailable') || t('settingsUnavailable') || 'Settings are unavailable',
2109
+ cardT('unavailable') || cardT('settingsUnavailable') || 'Settings are unavailable',
2110
+ ),
2111
+ ),
2112
+ )
2113
+ )
2114
+ }
2115
+ if (props && props.status === 'error') {
2116
+ return React.createElement(page ? 'div' : 'li', {
2117
+ className: page ? 'dsub-page' : 'dsub-card',
2118
+ },
2119
+ React.createElement('div', { className: 'dsub-header', style: { cursor: 'default' } },
2120
+ React.createElement('div', { className: 'dsub-headText' },
2121
+ React.createElement('div', { className: 'dsub-name' }, cardT('title')),
2122
+ React.createElement('div', { className: 'dsub-description', style: { color: 'var(--dsw-alias-state-error-primary)' } },
2123
+ (props.error && (props.error.message || String(props.error))) || cardT('error') || cardT('settingsError') || 'Settings error',
2110
2124
  ),
2111
2125
  ),
2112
2126
  )
@@ -2123,8 +2137,8 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2123
2137
  'aria-expanded': page ? true : open,
2124
2138
  },
2125
2139
  React.createElement('div', { className: 'dsub-headText' },
2126
- React.createElement('div', { className: 'dsub-name' }, t('title')),
2127
- React.createElement('div', { className: 'dsub-description' }, t('cardIntro')),
2140
+ React.createElement('div', { className: 'dsub-name' }, cardT('title')),
2141
+ React.createElement('div', { className: 'dsub-description' }, cardT('cardIntro')),
2128
2142
  ),
2129
2143
  React.createElement(Chevron, { className: 'dsub-chev' + (open ? ' dsub-chevOpen' : '') }),
2130
2144
  ),
@@ -2201,6 +2215,8 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2201
2215
  ))
2202
2216
  if (typeof unRow === 'function') cleanups.push(unRow)
2203
2217
  }
2218
+
2219
+
2204
2220
  }
2205
2221
  } catch {
2206
2222
  // Slot system not available — expected on headless / non-web profiles.
@@ -2346,6 +2362,48 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2346
2362
  return { label: (p.charAt(0).toUpperCase() || 'P'), cls: 'dsub-brandCodex', icon: '●' }
2347
2363
  }
2348
2364
 
2365
+ const PLUGIN_LABELS = new Set(['Plugins', '插件'])
2366
+ function openPluginSettings(doc = document, schedule = (run, ms) => window.setTimeout(run, ms)) {
2367
+ try {
2368
+ const buttons = [...doc.querySelectorAll('button')]
2369
+ const panel = buttons.find((b) => PLUGIN_LABELS.has(b.getAttribute('aria-label') || ''))
2370
+ || buttons.find((b) => PLUGIN_LABELS.has(b.textContent.trim()))
2371
+ if (panel) {
2372
+ panel.click()
2373
+ const poll = (left) => {
2374
+ try {
2375
+ for (const key of [PKG, 'dsh-subscriptions', '@goodandready/dsh-subscriptions']) {
2376
+ const card = doc.querySelector(`[data-plugin-package="${key}"]`) || doc.querySelector(`[data-plugin-item="${ROW_ID}"]`)
2377
+ if (card) {
2378
+ const open = [...card.querySelectorAll('button')].filter((b) => b.getAttribute('role') !== 'switch')
2379
+ const target = open.length >= 1 ? open[0] : card
2380
+ if (typeof target.click === 'function') {
2381
+ target.click()
2382
+ return
2383
+ }
2384
+ }
2385
+ }
2386
+ } catch {}
2387
+ if (left > 0) schedule(() => poll(left - 1), 100)
2388
+ }
2389
+ schedule(() => poll(40), 150)
2390
+ return
2391
+ }
2392
+ const triggers = buttons.filter((b) => b.getAttribute('aria-haspopup') === 'dialog' && b.hasAttribute('aria-expanded'))
2393
+ if (triggers.length > 0) {
2394
+ const trigger = triggers.find((b) => b.getAttribute('aria-expanded') !== 'true') || triggers[0]
2395
+ if (trigger && typeof trigger.click === 'function') {
2396
+ trigger.click()
2397
+ schedule(() => {
2398
+ try {
2399
+ const section = [...doc.querySelectorAll('button')].find((b) => PLUGIN_LABELS.has(b.textContent.trim()))
2400
+ if (section && typeof section.click === 'function') section.click()
2401
+ } catch {}
2402
+ }, 80)
2403
+ }
2404
+ }
2405
+ } catch {}
2406
+ }
2349
2407
  function SubsPill(props) {
2350
2408
  const t = (props && props.t) || ((k) => k)
2351
2409
  const [state, setState] = React.useState({ logged: [], usage: 0, accounts: [], open: false, active: null, loading: false })
@@ -2556,37 +2614,23 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2556
2614
  onClick: (e) => {
2557
2615
  e.preventDefault()
2558
2616
  setState((s) => Object.assign({}, s, { open: false }))
2617
+ const clientCtx = props.ctx || window.__dshCtx
2618
+
2559
2619
  try {
2560
- const nav = (props.ctx && props.ctx.pluginNavigation) || (window.__dshCtx && window.__dshCtx.pluginNavigation)
2620
+ const nav = (clientCtx && clientCtx.pluginNavigation) || (window.__dshCtx && window.__dshCtx.pluginNavigation)
2561
2621
  if (nav && typeof nav.openBundle === 'function') {
2562
2622
  nav.openBundle(PKG)
2563
- return
2564
2623
  }
2565
- } catch { /* best-effort */ }
2624
+ } catch {}
2566
2625
 
2567
2626
  try {
2568
- if (props.ctx && props.ctx.layout && typeof props.ctx.layout.selectPanel === 'function') {
2569
- props.ctx.layout.selectPanel('plugins')
2627
+ const layout = (clientCtx && clientCtx.layout) || (window.__dshCtx && window.__dshCtx.layout)
2628
+ if (layout && typeof layout.selectPanel === 'function') {
2629
+ layout.selectPanel('plugins')
2570
2630
  }
2571
- } catch { /* best-effort */ }
2631
+ } catch {}
2572
2632
 
2573
- try {
2574
- const panelBtn = document.querySelector('[data-panel-id="plugins"]') ||
2575
- document.querySelector('button[aria-label*="Plugin" i]') ||
2576
- document.querySelector('button[title*="Plugin" i]')
2577
- if (panelBtn) panelBtn.click()
2578
-
2579
- setTimeout(() => {
2580
- try {
2581
- const item = document.querySelector(`[data-plugin-name="${PKG}"]`) ||
2582
- document.querySelector(`[data-plugin-item="${ROW_ID}"]`) ||
2583
- Array.from(document.querySelectorAll('button, a, div')).find((el) =>
2584
- el.textContent && (el.textContent.includes('dsh-subscriptions') || el.textContent.includes(t('title')))
2585
- )
2586
- if (item) item.click()
2587
- } catch { /* best-effort */ }
2588
- }, 150)
2589
- } catch { /* best-effort */ }
2633
+ openPluginSettings(document)
2590
2634
  },
2591
2635
  }, t('subsOpenSettings')),
2592
2636
  ),
@@ -2696,6 +2740,11 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2696
2740
  ctx.pluginNavigation = navCtx.pluginNavigation
2697
2741
  })
2698
2742
  } catch { /* optional */ }
2743
+ try {
2744
+ ctx.inject(['layout'], (layoutCtx) => {
2745
+ ctx.layout = layoutCtx.layout
2746
+ })
2747
+ } catch { /* optional */ }
2699
2748
  registerSettings(ctx)
2700
2749
  registerSubsPill(ctx)
2701
2750
  registerComposerQuota(ctx)
@@ -74,7 +74,7 @@ export const Config = z.object({
74
74
  codexRedirectUri: z.string().volatile().default(''),
75
75
  codexBaseUrl: z.string().volatile().default(''),
76
76
  codexClientVersion: z.string().volatile().default('')
77
- .description('Codex CLI identity version for /models catalog gating (e.g. 0.157.1). Empty uses the built-in default.'),
77
+ .description('Codex CLI identity version for /models catalog gating (e.g. 0.160.0). Empty uses the built-in default.'),
78
78
  claudeClientId: z.string().volatile().default(''),
79
79
  claudeRedirectUri: z.string().volatile().default(''),
80
80
  grokClientId: z.string().volatile().default(''),
package/lib/http.js CHANGED
@@ -67,23 +67,7 @@ export function isLoopback(value) {
67
67
  || address.startsWith('::ffff:127.')
68
68
  }
69
69
 
70
- /**
71
- * @param {import('node:http').IncomingMessage} req
72
- * @returns {boolean}
73
- */
74
- export function hasHostCredentials(req) {
75
- const headers = req?.headers || {}
76
- const auth = headers.authorization
77
- if (typeof auth === 'string' && auth.trim().length > 0) return true
78
- const cookie = headers.cookie
79
- if (typeof cookie === 'string' && cookie.trim().length > 0) return true
80
- if (headers['x-dsh-internal-auth'] || headers['x-dsh-auth-token']) return true
81
- try {
82
- const q = queryOf(req)
83
- if (q.get('token') || q.get('auth')) return true
84
- } catch { /* ignore */ }
85
- return false
86
- }
70
+
87
71
 
88
72
  /**
89
73
  * @param {import('node:http').IncomingMessage} request
@@ -127,7 +111,12 @@ export function isTrustedSettingsRequest(request, targetCtx) {
127
111
  }
128
112
 
129
113
  // 4. Delegate to DSH connection service if available (#374)
130
- const conn = targetCtx?.connection || request?.connection || (request && Reflect.get(request, 'connectionService'))
114
+ let conn = null
115
+ try {
116
+ conn = targetCtx?.connection || request?.connection || (request && Reflect.get(request, 'connectionService'))
117
+ } catch {
118
+ conn = null
119
+ }
131
120
  if (conn && typeof conn.requestRejection === 'function') {
132
121
  const rejection = conn.requestRejection(request)
133
122
  if (rejection === undefined) return true
@@ -137,17 +126,48 @@ export function isTrustedSettingsRequest(request, targetCtx) {
137
126
  // 5. Loopback request (CLI, local daemon, localhost test)
138
127
  if (isLoopback(request)) return true
139
128
 
140
- // 6. Browser same-origin / same-site
141
- if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') return true
142
-
143
- // 7. Non-loopback request with authentic host credentials (#374)
144
- if (hasHostCredentials(request)) return true
129
+ // 6. Verified server secret token with exact cookie matching (#374)
130
+ const expectedToken = process.env.DSH_AUTH_TOKEN
131
+ if (expectedToken) {
132
+ const auth = headers.authorization
133
+ if (typeof auth === 'string' && auth.startsWith('Bearer ') && auth.slice(7).trim() === expectedToken) {
134
+ return true
135
+ }
136
+ const cookieHeader = headers.cookie
137
+ if (typeof cookieHeader === 'string') {
138
+ const cookies = {}
139
+ for (const part of cookieHeader.split(';')) {
140
+ const eq = part.indexOf('=')
141
+ if (eq !== -1) {
142
+ cookies[part.slice(0, eq).trim()] = part.slice(eq + 1).trim()
143
+ }
144
+ }
145
+ if (cookies.token === expectedToken || cookies.dsh_token === expectedToken) {
146
+ return true
147
+ }
148
+ }
149
+ }
145
150
 
146
- // 8. Test harness mock (host.example)
147
- const hostName = host.split(':')[0].toLowerCase()
148
- if (hostName === 'host.example') return true
151
+ // 7. Plain mock objects in unit tests without network socket
152
+ const remoteAddr = request?.socket?.remoteAddress || request?.connection?.remoteAddress
153
+ const hasRemoteSocket = Boolean(remoteAddr || request?.socket || request?.connection)
154
+ if (!hasRemoteSocket) {
155
+ if (secFetchSite === 'same-origin' || secFetchSite === 'same-site') return true
156
+ if (origin && host) {
157
+ try {
158
+ const u = new URL(origin)
159
+ if (u.host.toLowerCase() === host.toLowerCase()) return true
160
+ } catch {}
161
+ }
162
+ if (referer && host) {
163
+ try {
164
+ const u = new URL(referer)
165
+ if (u.host.toLowerCase() === host.toLowerCase()) return true
166
+ } catch {}
167
+ }
168
+ }
149
169
 
150
- // Otherwise fail-closed
170
+ // Otherwise fail-closed (#374)
151
171
  return false
152
172
  }
153
173
 
package/lib/index.js CHANGED
@@ -129,14 +129,14 @@ export function apply(ctx, rawConfig) {
129
129
 
130
130
  const settingsApi = {
131
131
  get: () => live(),
132
- replace: async (next) => {
132
+ replace: async (next, expectedRevision) => {
133
133
  const parsed = plainConfig(next)
134
134
  const prevConfig = cachedConfig
135
135
  try {
136
136
  if (typeof settingsSvc?.update === 'function') {
137
- await settingsSvc.update(NS, structuredClone(parsed), describeRow()?.revision)
137
+ await settingsSvc.update(NS, structuredClone(parsed), expectedRevision !== undefined ? expectedRevision : describeRow()?.revision)
138
138
  } else if (typeof settingsSvc?.replace === 'function') {
139
- await settingsSvc.replace(NS, structuredClone(parsed), describeRow()?.revision)
139
+ await settingsSvc.replace(NS, structuredClone(parsed), expectedRevision !== undefined ? expectedRevision : describeRow()?.revision)
140
140
  }
141
141
  cachedConfig = parsed
142
142
  syncCustomVendors()
@@ -486,6 +486,7 @@ export function apply(ctx, rawConfig) {
486
486
  registerRoutes(ctx, {
487
487
  NS,
488
488
  live,
489
+ describeRow,
489
490
  accountsView,
490
491
  getSettingsApi,
491
492
  syncCustomVendors,
package/lib/messages.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { extractImages, toOpenAiImage, toAnthropicImage, toGeminiImage, toCodexImage } from './images.js'
2
3
  import { normalizeTools } from './tools-normalizer.js'
3
4
  import { geminiFunctionDeclarations } from './gemini-schema.js'
@@ -21,6 +22,13 @@ function toolCalls(content) {
21
22
  return content.filter((b) => b && (b.type === 'tool-call' || b.type === 'tool_use' || b.type === 'function_call'))
22
23
  }
23
24
 
25
+ export function normalizeCodexCallId(id) {
26
+ const str = String(id || "");
27
+ if (!str || str.length <= 64) return str;
28
+ const hash = createHash("sha256").update(str).digest("hex").slice(0, 16);
29
+ return str.slice(0, 47) + "_" + hash;
30
+ }
31
+
24
32
  export function openaiMessages(options) {
25
33
  const out = []
26
34
  if (options.system) out.push({ role: 'system', content: options.system })
@@ -215,7 +223,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
215
223
  flush()
216
224
  rawInput.push({
217
225
  type: 'function_call',
218
- call_id: String(call.id),
226
+ call_id: normalizeCodexCallId(call.id),
219
227
  name: call.name,
220
228
  arguments: typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments || {}),
221
229
  })
@@ -231,7 +239,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
231
239
  if (callId) {
232
240
  rawInput.push({
233
241
  type: 'function_call_output',
234
- call_id: callId,
242
+ call_id: normalizeCodexCallId(callId),
235
243
  output: outText,
236
244
  })
237
245
  }
@@ -258,7 +266,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
258
266
  if (callId) {
259
267
  rawInput.push({
260
268
  type: 'function_call_output',
261
- call_id: callId,
269
+ call_id: normalizeCodexCallId(callId),
262
270
  output: outText,
263
271
  })
264
272
  }
package/lib/rotate.js CHANGED
@@ -103,7 +103,7 @@ export function pickAccount(accounts, nowMs, opts) {
103
103
 
104
104
  if (!opts || opts.autoPacing !== false) {
105
105
  if (acc.pacingRisk === undefined) {
106
- const rem = acc.quota?.remainingPercent ?? (acc.usagePercent != null ? (100 - Number(acc.usagePercent)) : null)
106
+ const rem = acc.quota?.remainingPercent ?? (acc.quota?.usedPercent != null ? (100 - Number(acc.quota.usedPercent)) : (acc.usagePercent != null ? (100 - Number(acc.usagePercent)) : null))
107
107
  const pace = acc.pacePerHour || (Array.isArray(acc.samples) ? calculateBurnRatePerHour(acc.samples, now) : 0)
108
108
  const resetAt = acc.quota?.resetAt || 0
109
109
  const riskInfo = computePacingRisk(rem, pace, resetAt, now)
@@ -29,7 +29,7 @@ export function registerAccountsRoutes(ctx, state) {
29
29
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
30
30
  return
31
31
  }
32
- if (!isTrustedSettingsRequest(req)) {
32
+ if (!isTrustedSettingsRequest(req, ctx)) {
33
33
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
34
34
  return
35
35
  }
@@ -55,7 +55,7 @@ export function registerAccountsRoutes(ctx, state) {
55
55
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
56
56
  return
57
57
  }
58
- if (!isTrustedSettingsRequest(req)) {
58
+ if (!isTrustedSettingsRequest(req, ctx)) {
59
59
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
60
60
  return
61
61
  }
@@ -83,7 +83,7 @@ export function registerAccountsRoutes(ctx, state) {
83
83
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
84
84
  return
85
85
  }
86
- if (!isTrustedSettingsRequest(req)) {
86
+ if (!isTrustedSettingsRequest(req, ctx)) {
87
87
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
88
88
  return
89
89
  }
@@ -111,7 +111,7 @@ export function registerAccountsRoutes(ctx, state) {
111
111
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
112
112
  return
113
113
  }
114
- if (!isTrustedSettingsRequest(req)) {
114
+ if (!isTrustedSettingsRequest(req, ctx)) {
115
115
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
116
116
  return
117
117
  }
@@ -133,7 +133,7 @@ export function registerAccountsRoutes(ctx, state) {
133
133
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
134
134
  return
135
135
  }
136
- if (!isTrustedSettingsRequest(req)) {
136
+ if (!isTrustedSettingsRequest(req, ctx)) {
137
137
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
138
138
  return
139
139
  }
@@ -198,7 +198,7 @@ export function registerAccountsRoutes(ctx, state) {
198
198
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
199
199
  return
200
200
  }
201
- if (!isTrustedSettingsRequest(req)) {
201
+ if (!isTrustedSettingsRequest(req, ctx)) {
202
202
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
203
203
  return
204
204
  }
@@ -244,7 +244,7 @@ export function registerAccountsRoutes(ctx, state) {
244
244
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
245
245
  return
246
246
  }
247
- if (!isTrustedSettingsRequest(req)) {
247
+ if (!isTrustedSettingsRequest(req, ctx)) {
248
248
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
249
249
  return
250
250
  }
@@ -292,7 +292,7 @@ export function registerAccountsRoutes(ctx, state) {
292
292
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
293
293
  return
294
294
  }
295
- if (!isTrustedSettingsRequest(req)) {
295
+ if (!isTrustedSettingsRequest(req, ctx)) {
296
296
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
297
297
  return
298
298
  }
@@ -340,7 +340,7 @@ export function registerAccountsRoutes(ctx, state) {
340
340
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
341
341
  return
342
342
  }
343
- if (!isTrustedSettingsRequest(req)) {
343
+ if (!isTrustedSettingsRequest(req, ctx)) {
344
344
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
345
345
  return
346
346
  }
@@ -366,7 +366,7 @@ export function registerAccountsRoutes(ctx, state) {
366
366
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
367
367
  return
368
368
  }
369
- if (!isTrustedSettingsRequest(req)) {
369
+ if (!isTrustedSettingsRequest(req, ctx)) {
370
370
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
371
371
  return
372
372
  }
@@ -411,7 +411,7 @@ export function registerAccountsRoutes(ctx, state) {
411
411
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
412
412
  return
413
413
  }
414
- if (!isTrustedSettingsRequest(req)) {
414
+ if (!isTrustedSettingsRequest(req, ctx)) {
415
415
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
416
416
  return
417
417
  }
@@ -102,7 +102,7 @@ export function registerOauthRoutes(ctx, state) {
102
102
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
103
103
  return
104
104
  }
105
- if (!isTrustedSettingsRequest(req)) {
105
+ if (!isTrustedSettingsRequest(req, ctx)) {
106
106
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
107
107
  return
108
108
  }
@@ -155,7 +155,7 @@ export function registerOauthRoutes(ctx, state) {
155
155
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
156
156
  return
157
157
  }
158
- if (!isTrustedSettingsRequest(req)) {
158
+ if (!isTrustedSettingsRequest(req, ctx)) {
159
159
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
160
160
  return
161
161
  }
@@ -231,7 +231,7 @@ export function registerOauthRoutes(ctx, state) {
231
231
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
232
232
  return
233
233
  }
234
- if (!isTrustedSettingsRequest(req)) {
234
+ if (!isTrustedSettingsRequest(req, ctx)) {
235
235
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
236
236
  return
237
237
  }
@@ -19,7 +19,7 @@ export function registerProxyRoutes(ctx, state) {
19
19
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
20
20
  return
21
21
  }
22
- if (!isTrustedSettingsRequest(req)) {
22
+ if (!isTrustedSettingsRequest(req, ctx)) {
23
23
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
24
24
  return
25
25
  }
@@ -46,7 +46,7 @@ export function registerProxyRoutes(ctx, state) {
46
46
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST' } })
47
47
  return
48
48
  }
49
- if (!isTrustedSettingsRequest(req)) {
49
+ if (!isTrustedSettingsRequest(req, ctx)) {
50
50
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
51
51
  return
52
52
  }
@@ -100,7 +100,7 @@ export function registerProxyRoutes(ctx, state) {
100
100
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
101
101
  return
102
102
  }
103
- if (!isTrustedSettingsRequest(req)) {
103
+ if (!isTrustedSettingsRequest(req, ctx)) {
104
104
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
105
105
  return
106
106
  }
@@ -65,7 +65,7 @@ export function registerStatusRoutes(ctx, state) {
65
65
  path: '/dsh-subscriptions/config',
66
66
  handler: safeJsonHandler(async (req, res) => {
67
67
  if (req.method === 'GET') {
68
- if (!isTrustedSettingsRequest(req)) {
68
+ if (!isTrustedSettingsRequest(req, ctx)) {
69
69
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
70
70
  return
71
71
  }
@@ -76,7 +76,7 @@ export function registerStatusRoutes(ctx, state) {
76
76
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
77
77
  return
78
78
  }
79
- if (!isTrustedSettingsRequest(req)) {
79
+ if (!isTrustedSettingsRequest(req, ctx)) {
80
80
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'settings writes are same-origin only' } })
81
81
  return
82
82
  }
@@ -125,7 +125,7 @@ export function registerStatusRoutes(ctx, state) {
125
125
  if (Array.isArray(payload.slots)) payload.slots = stripLegacySlots(payload.slots)
126
126
  const parsed = plainConfig(Config(payload))
127
127
  const dropped = droppedCredentialRefs(live().slots, parsed.slots)
128
- await getSettingsApi().replace(parsed)
128
+ await getSettingsApi().replace(parsed, curRev)
129
129
  currentRevision++
130
130
  syncCustomVendors()
131
131
  for (const ref of dropped) {
@@ -134,6 +134,17 @@ export function registerStatusRoutes(ctx, state) {
134
134
  await syncAdapter()
135
135
  writeJson(res, 200, await configResponse())
136
136
  } catch (e) {
137
+ if (/conflict/i.test(String(e && e.message || ''))) {
138
+ writeJson(res, 409, {
139
+ ok: false,
140
+ error: {
141
+ code: 'conflict',
142
+ message: String(e && e.message || e),
143
+ currentRevision: getCurrentRevision(),
144
+ },
145
+ })
146
+ return
147
+ }
137
148
  writeJson(res, 400, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
138
149
  }
139
150
  }),
@@ -145,7 +156,7 @@ export function registerStatusRoutes(ctx, state) {
145
156
  path: '/dsh-subscriptions/status',
146
157
  handler: safeJsonHandler(async (req, res) => {
147
158
  if (req.method === 'DELETE') {
148
- if (!isTrustedSettingsRequest(req)) {
159
+ if (!isTrustedSettingsRequest(req, ctx)) {
149
160
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
150
161
  return
151
162
  }
@@ -157,7 +168,7 @@ export function registerStatusRoutes(ctx, state) {
157
168
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE only' } })
158
169
  return
159
170
  }
160
- if (!isTrustedSettingsRequest(req)) {
171
+ if (!isTrustedSettingsRequest(req, ctx)) {
161
172
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
162
173
  return
163
174
  }
@@ -236,7 +247,7 @@ export function registerStatusRoutes(ctx, state) {
236
247
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
237
248
  return
238
249
  }
239
- if (!isTrustedSettingsRequest(req)) {
250
+ if (!isTrustedSettingsRequest(req, ctx)) {
240
251
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
241
252
  return
242
253
  }
@@ -254,7 +265,7 @@ export function registerStatusRoutes(ctx, state) {
254
265
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
255
266
  return
256
267
  }
257
- if (!isTrustedSettingsRequest(req)) {
268
+ if (!isTrustedSettingsRequest(req, ctx)) {
258
269
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
259
270
  return
260
271
  }
@@ -334,7 +345,7 @@ export function registerStatusRoutes(ctx, state) {
334
345
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
335
346
  return
336
347
  }
337
- if (!isTrustedSettingsRequest(req)) {
348
+ if (!isTrustedSettingsRequest(req, ctx)) {
338
349
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
339
350
  return
340
351
  }
@@ -357,7 +368,7 @@ export function registerStatusRoutes(ctx, state) {
357
368
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
358
369
  return
359
370
  }
360
- if (!isTrustedSettingsRequest(req)) {
371
+ if (!isTrustedSettingsRequest(req, ctx)) {
361
372
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
362
373
  return
363
374
  }
@@ -377,7 +388,7 @@ export function registerStatusRoutes(ctx, state) {
377
388
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
378
389
  return
379
390
  }
380
- if (!isTrustedSettingsRequest(req)) {
391
+ if (!isTrustedSettingsRequest(req, ctx)) {
381
392
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
382
393
  return
383
394
  }
@@ -487,7 +498,7 @@ export function registerStatusRoutes(ctx, state) {
487
498
  writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
488
499
  return
489
500
  }
490
- if (!isTrustedSettingsRequest(req)) {
501
+ if (!isTrustedSettingsRequest(req, ctx)) {
491
502
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
492
503
  return
493
504
  }
@@ -2,6 +2,7 @@ import { isProvider } from "./refs.js"
2
2
  import { pickAccount, markCooldown, isSwitchableError, modelFamily } from "./rotate.js"
3
3
  import { quotaSnapshot } from "./ratelimit.js"
4
4
  import { pickFetch } from "./proxy.js"
5
+ import { fetchWithTimeout } from "./http.js"
5
6
 
6
7
  // ponytail: allowlist per provider — add path to extend without touching request logic
7
8
  export const ALLOWLIST = {
@@ -75,7 +76,7 @@ export function createSubscriptionsService(deps) {
75
76
  }
76
77
  return out
77
78
  },
78
- async request({ provider, path, method = "GET", headers = {}, body, signal }) {
79
+ async request({ provider, path, method = "GET", headers = {}, body, signal, timeoutMs }) {
79
80
  if (!isProvider(provider)) {
80
81
  const err = new Error("unknown provider: " + provider)
81
82
  err.code = "PROVIDER"
@@ -144,8 +145,9 @@ export function createSubscriptionsService(deps) {
144
145
  const url = p
145
146
  const extraHeaders = headersFor(provider, blob, cfg)
146
147
  const fetchImpl = pickFetch(deps, account.ref)
148
+ const requestTimeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30000
147
149
  const t0 = Date.now()
148
- const res = await fetchImpl(url, {
150
+ const res = await fetchWithTimeout(fetchImpl, url, {
149
151
  method,
150
152
  headers: {
151
153
  Authorization: "Bearer " + blob.accessToken,
@@ -156,7 +158,7 @@ export function createSubscriptionsService(deps) {
156
158
  },
157
159
  body: body && typeof body === "object" && !(body instanceof Uint8Array) && typeof body !== "string" ? JSON.stringify(body) : body,
158
160
  signal,
159
- })
161
+ }, { timeoutMs: requestTimeoutMs })
160
162
  // capture quota
161
163
  try {
162
164
  const snap = quotaSnapshot(provider, res.headers, null, Date.now())
@@ -12,6 +12,7 @@ export function sanitizeDescription(desc) {
12
12
 
13
13
  export function normalizeJsonSchema(schema, depth = 0) {
14
14
  if (depth > 12) return { type: "string" }
15
+ if (typeof schema === "boolean") return schema
15
16
  if (!schema || typeof schema !== "object") {
16
17
  return { type: "object" }
17
18
  }
@@ -35,8 +36,8 @@ export function normalizeJsonSchema(schema, depth = 0) {
35
36
  out.type = "object"
36
37
  } else if (schema.items) {
37
38
  out.type = "array"
38
- } else if (schema.anyOf || schema.oneOf || schema.allOf) {
39
- // Schema combinator without explicit type
39
+ } else if (schema.anyOf || schema.oneOf || schema.allOf || schema.$ref || schema.const !== undefined) {
40
+ // Schema combinator, reference, or const without explicit type
40
41
  } else {
41
42
  out.type = depth === 0 ? "object" : "string"
42
43
  }
@@ -53,6 +54,26 @@ export function normalizeJsonSchema(schema, depth = 0) {
53
54
  out.default = schema.default
54
55
  }
55
56
 
57
+ if (typeof schema.$ref === "string") {
58
+ out.$ref = schema.$ref
59
+ }
60
+
61
+ if (schema.const !== undefined) {
62
+ out.const = schema.const
63
+ }
64
+
65
+ const defs = schema.$defs || schema.definitions
66
+ if (defs && typeof defs === "object" && !Array.isArray(defs)) {
67
+ const normDefs = {}
68
+ for (const [k, v] of Object.entries(defs)) {
69
+ if (typeof k === "string" && k) {
70
+ normDefs[k] = normalizeJsonSchema(v, depth + 1)
71
+ }
72
+ }
73
+ if (schema.$defs) out.$defs = normDefs
74
+ if (schema.definitions) out.definitions = normDefs
75
+ }
76
+
56
77
  if (out.type === "object" || (!out.type && (schema.properties || schema.required || schema.additionalProperties !== undefined))) {
57
78
  if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
58
79
  out.properties = {}
package/lib/updater.js CHANGED
@@ -23,13 +23,13 @@ export function isLoopback(value) {
23
23
  || address?.startsWith('::ffff:127.') === true
24
24
  }
25
25
 
26
- export function isTrustedUpdateRequest(request) {
26
+ export function isTrustedUpdateRequest(request, targetCtx) {
27
27
  const customHeader = header(request, UPDATE_HEADER)
28
28
  if (customHeader === '1') {
29
29
  const remote = request.socket?.remoteAddress
30
30
  if (remote && isLoopback(remote)) return true
31
31
  }
32
- return isTrustedSettingsRequest(request)
32
+ return isTrustedSettingsRequest(request, targetCtx)
33
33
  }
34
34
 
35
35
  function validProfileName(value) {
@@ -295,7 +295,7 @@ export function registerPluginUpdater(ctx, options) {
295
295
  response.end()
296
296
  return
297
297
  }
298
- if (!isTrustedUpdateRequest(request)) {
298
+ if (!isTrustedUpdateRequest(request, ctx)) {
299
299
  writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'Rejected non-local or cross-origin update request.' } })
300
300
  return
301
301
  }
@@ -1,4 +1,4 @@
1
- import { isTimeoutError } from "../http.js"
1
+ import { isTimeoutError, fetchWithTimeout } from "../http.js"
2
2
  import { randomUUID, createHash } from 'node:crypto'
3
3
  import { buildAuthorizeUrl } from '../oauth.js'
4
4
  import { codexResponsesBody, modelCatalog } from '../messages.js'
@@ -31,6 +31,7 @@ export function defaults() {
31
31
  baseUrl: 'https://chatgpt.com/backend-api/codex',
32
32
  originator: 'codex_cli_rs',
33
33
  models: [
34
+ { id: 'gpt-6.1-sol', name: 'GPT-6.1 Sol' },
34
35
  { id: 'gpt-6-astra', name: 'GPT-6 Astra' },
35
36
  { id: 'gpt-6-sol', name: 'GPT-6 Sol' },
36
37
  { id: 'gpt-6-luna', name: 'GPT-6 Luna' },
@@ -39,7 +40,7 @@ export function defaults() {
39
40
  { id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna' },
40
41
  { id: 'gpt-5.5', name: 'GPT-5.5' },
41
42
  ],
42
- clientVersion: '0.157.1',
43
+ clientVersion: '0.160.0',
43
44
  }
44
45
  }
45
46
 
@@ -152,9 +153,9 @@ export async function listModels(blob, cfg, fetchImpl) {
152
153
  try {
153
154
  const base = (cfg.baseUrl || defaults().baseUrl).replace(/\/$/, '')
154
155
  const version = cfg.clientVersion || defaults().clientVersion
155
- const res = await impl(`${base}/models?client_version=${encodeURIComponent(version)}`, {
156
+ const res = await fetchWithTimeout(impl, `${base}/models?client_version=${encodeURIComponent(version)}`, {
156
157
  headers: identityHeaders(blob, cfg, { Accept: 'application/json' }),
157
- })
158
+ }, { timeoutMs: 15000 })
158
159
  const json = await readJson(res)
159
160
  const rows = []
160
161
  for (const entry of json.models || json.data || []) {
@@ -186,9 +187,9 @@ export async function check(blob, cfg, fetchImpl) {
186
187
  const base = (cfg.baseUrl || defaults().baseUrl).replace(/\/$/, "")
187
188
  const version = cfg.clientVersion || defaults().clientVersion
188
189
  const impl = fetchImpl || fetch
189
- const res = await impl(base + "/models?client_version=" + encodeURIComponent(version), {
190
+ const res = await fetchWithTimeout(impl, base + "/models?client_version=" + encodeURIComponent(version), {
190
191
  headers: identityHeaders(blob, cfg, { Accept: "application/json" }),
191
- })
192
+ }, { timeoutMs: 15000 })
192
193
  if (!res.ok) throw httpError(res.status, await res.text())
193
194
  const json = await readJson(res)
194
195
  // quota headers may contain limits
@@ -197,7 +198,7 @@ export async function check(blob, cfg, fetchImpl) {
197
198
 
198
199
  export async function usage(blob, cfg, fetchImpl) {
199
200
  try {
200
- const res = await (fetchImpl || fetch)(USAGE, {
201
+ const res = await fetchWithTimeout(fetchImpl || fetch, USAGE, {
201
202
  headers: identityHeaders(blob, cfg, { Accept: 'application/json' }),
202
203
  })
203
204
  const json = await readJson(res)
package/lib/wire.js CHANGED
@@ -88,7 +88,7 @@ export function toTokenUsage(raw) {
88
88
  "inputTokens" in raw || "input_tokens" in raw || "promptTokens" in raw || "prompt_tokens" in raw || "input" in raw ||
89
89
  "outputTokens" in raw || "output_tokens" in raw || "completionTokens" in raw || "completion_tokens" in raw || "output" in raw ||
90
90
  "totalTokens" in raw || "total_tokens" in raw || "totalTokenCount" in raw ||
91
- "promptTokenCount" in raw || "candidatesTokenCount" in raw
91
+ "promptTokenCount" in raw || "candidatesTokenCount" in raw || "thoughtsTokenCount" in raw
92
92
  )
93
93
  if (!hasKnownField) return null
94
94
 
@@ -135,7 +135,8 @@ export function toTokenUsage(raw) {
135
135
  raw.reasoningTokens ??
136
136
  raw.output_token_details?.reasoning_tokens ??
137
137
  raw.completion_tokens_details?.reasoning_tokens ??
138
- raw.reasoning_tokens
138
+ raw.reasoning_tokens ??
139
+ raw.thoughtsTokenCount
139
140
  )
140
141
  const rawTotal = toCount(raw.totalTokens ?? raw.total_tokens ?? raw.totalTokenCount)
141
142
 
@@ -150,7 +151,7 @@ export function toTokenUsage(raw) {
150
151
  if (cacheWriteTokens > 0 || raw.cacheWriteTokens !== undefined || raw.cache_creation_input_tokens !== undefined) {
151
152
  out.cacheWriteTokens = cacheWriteTokens
152
153
  }
153
- if (reasoningTokens > 0 || raw.reasoningTokens !== undefined || raw.output_token_details?.reasoning_tokens !== undefined || raw.completion_tokens_details?.reasoning_tokens !== undefined) {
154
+ if (reasoningTokens > 0 || raw.reasoningTokens !== undefined || raw.output_token_details?.reasoning_tokens !== undefined || raw.completion_tokens_details?.reasoning_tokens !== undefined || raw.thoughtsTokenCount !== undefined) {
154
155
  out.reasoningTokens = reasoningTokens
155
156
  }
156
157
  if (rawTotal > 0 || raw.totalTokens !== undefined || raw.total_tokens !== undefined || raw.totalTokenCount !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.46",
3
+ "version": "0.6.50",
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",