@goodandready/dsh-subscriptions 0.6.40 → 0.6.43

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,5 +1,5 @@
1
1
  import { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure, STATUS_QUARANTINE, STATUS_PROBING } from "./quarantine.js"
2
- import { fetchWithTimeout } from "./http.js"
2
+ import { fetchWithTimeout, isTimeoutError } from "./http.js"
3
3
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
4
4
  import { oauthRef, isProvider } from './refs.js'
5
5
  import { parseBlob, serializeBlob } from './blob.js'
@@ -75,7 +75,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
75
75
  const doFetch = fetchImpl || fetch
76
76
  // #88: per-account proxy fetch for token refresh / usage (falls back to doFetch).
77
77
  const rawFetchFor = (ref) => (typeof fetchForRef === 'function' && fetchForRef(ref)) || doFetch
78
- const fetchFor = (ref) => (url, init, opts) => fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs: 25000, ...(opts || {}) })
78
+ const fetchFor = (ref, defaultTimeoutMs = 25000) => (url, init, opts) => fetchWithTimeout(rawFetchFor(ref), url, init, { timeoutMs: defaultTimeoutMs, ...(opts || {}) })
79
79
 
80
80
  async function resolveRaw(ref) {
81
81
  try {
@@ -89,7 +89,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
89
89
  async function loadBlob(ref) {
90
90
  const raw = await resolveRaw(ref)
91
91
  if (!raw) {
92
- const err = new Error(`no token for ${ref}`)
92
+ const err = /** @type {Error & { code?: string }} */ (new Error(`no token for ${ref}`))
93
93
  err.code = 'AUTH'
94
94
  throw err
95
95
  }
@@ -103,7 +103,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
103
103
  try {
104
104
  await credentials.set(credentialRef(ref), serializeBlob(blob))
105
105
  } catch (err) {
106
- const wrapErr = new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`)
106
+ const wrapErr = /** @type {Error & { code?: string, cause?: any }} */ (new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`))
107
107
  wrapErr.code = "CREDENTIAL_SAVE_FAILED"
108
108
  wrapErr.cause = err
109
109
  throw wrapErr
@@ -171,6 +171,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
171
171
  healthBadge: healthBadge(computeHealthScore(health.get(ref) || null)),
172
172
  usagePercent: usage.has(ref) ? usage.get(ref) : null,
173
173
  refreshError: refreshFailures.get(ref)?.error || '',
174
+ isTimeout: !!refreshFailures.get(ref)?.isTimeout,
174
175
  validationUrl: base.validationUrl || '',
175
176
  validationMessage: base.validationMessage || '',
176
177
  accountNotice: base.accountNotice || '',
@@ -227,7 +228,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
227
228
  const curConfig = getConfig()
228
229
  const cfg = vendorConfig(provider, curConfig)
229
230
  const next = await executeWithRetry(
230
- () => getVendor(provider).refresh(cfg, blob, fetchFor(ref)),
231
+ () => getVendor(provider).refresh(cfg, blob, fetchFor(ref, 30000)),
231
232
  {
232
233
  maxRetries: 2,
233
234
  initialDelayMs: 300,
@@ -250,7 +251,11 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
250
251
  if (ref) refreshFailures.delete(ref)
251
252
  return merged
252
253
  } catch (e) {
253
- if (ref) refreshFailures.set(ref, { at: Date.now(), error: String(e && e.message || e) })
254
+ const isTimeout = isTimeoutError(e)
255
+ const errMsg = isTimeout
256
+ ? `Network timeout refreshing token: ${e.message || 'timed out'}`
257
+ : String(e && e.message || e)
258
+ if (ref) refreshFailures.set(ref, { at: Date.now(), error: errMsg, isTimeout })
254
259
  throw e
255
260
  } finally {
256
261
  refreshLocks.delete(lockKey)
@@ -260,59 +265,89 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
260
265
  return promise
261
266
  }
262
267
 
268
+ const providerUsageLocks = new Map()
269
+ const slotUsageLocks = new Map()
270
+
263
271
  async function refreshUsage(provider) {
264
- const curConfig = getConfig()
265
- const cfg = vendorConfig(provider, curConfig)
266
- const vendor = getVendor(provider)
267
- const slots = normalizeSlots(curConfig.slots).filter((s) => s.provider === provider)
268
- for (const slot of slots) {
269
- const last = usageFetched.get(slot.ref) || 0
270
- if (Date.now() - last < USAGE_TTL_MS) continue
271
- let raw = ''
272
- try { raw = await resolveRaw(slot.ref) } catch { continue }
273
- if (!raw) continue
274
- try {
275
- const blob = await ensureFresh(provider, parseBlob(raw), slot.ref)
276
- const snap = await vendor.usage(blob, cfg, fetchFor(slot.ref))
277
- usageFetched.set(slot.ref, Date.now())
278
- if (!snap) continue
279
- if (Number.isFinite(Number(snap.usedPercent))) {
280
- usage.set(slot.ref, Number(snap.usedPercent))
272
+ if (providerUsageLocks.has(provider)) {
273
+ return providerUsageLocks.get(provider)
274
+ }
275
+ const flight = (async () => {
276
+ const curConfig = getConfig()
277
+ const cfg = vendorConfig(provider, curConfig)
278
+ const vendor = getVendor(provider)
279
+ const slots = normalizeSlots(curConfig.slots).filter((s) => s.provider === provider)
280
+ for (const slot of slots) {
281
+ if (slotUsageLocks.has(slot.ref)) {
282
+ await slotUsageLocks.get(slot.ref).catch(() => {})
283
+ continue
281
284
  }
282
- // Notification thresholds: 70/90/100% — fire once per window until reset.
283
- if (Array.isArray(snap.windows)) {
284
- for (const win of snap.windows) {
285
- if (!win || !Number.isFinite(Number(win.usedPercent))) continue
286
- const pct = Number(win.usedPercent)
287
- const key = slot.ref + ':' + win.id
288
- const prev = notifyThresholds.get(key) || 0
289
- const THRESHOLDS = [70, 90, 100]
290
- for (const th of THRESHOLDS) {
291
- if (pct >= th && prev < th) {
292
- notifyThresholds.set(key, th)
293
- try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch { /* ignore notification failure */ }
294
- break
285
+ const last = usageFetched.get(slot.ref) || 0
286
+ if (Date.now() - last < USAGE_TTL_MS) continue
287
+ let raw = ''
288
+ try { raw = await resolveRaw(slot.ref) } catch { continue }
289
+ if (!raw) continue
290
+
291
+ const slotFlight = (async () => {
292
+ try {
293
+ usageFetched.set(slot.ref, Date.now())
294
+ const blob = await ensureFresh(provider, parseBlob(raw), slot.ref)
295
+ const usageTimeout = Number(curConfig?.usageTimeoutMs) || 15000
296
+ const snap = await vendor.usage(blob, cfg, fetchFor(slot.ref, usageTimeout))
297
+ if (!snap) return
298
+ if (Number.isFinite(Number(snap.usedPercent))) {
299
+ usage.set(slot.ref, Number(snap.usedPercent))
300
+ }
301
+ // Notification thresholds: 70/90/100% — fire once per window until reset.
302
+ if (Array.isArray(snap.windows)) {
303
+ for (const win of snap.windows) {
304
+ if (!win || !Number.isFinite(Number(win.usedPercent))) continue
305
+ const pct = Number(win.usedPercent)
306
+ const key = slot.ref + ':' + win.id
307
+ const prev = notifyThresholds.get(key) || 0
308
+ const THRESHOLDS = [70, 90, 100]
309
+ for (const th of THRESHOLDS) {
310
+ if (pct >= th && prev < th) {
311
+ notifyThresholds.set(key, th)
312
+ try { onLimitNotice && onLimitNotice(slot.provider, slot.ref, win, th) } catch { /* ignore notification failure */ }
313
+ break
314
+ }
315
+ }
295
316
  }
296
317
  }
297
- }
298
- }
299
- const list = Array.isArray(snap.windows) ? snap.windows : null
300
- if (list) {
301
- windows.set(slot.ref, list)
302
- // persist last known windows so the card survives restarts
303
- try {
304
- const prev = Array.isArray(blob.usage) ? JSON.stringify(blob.usage) : ''
305
- if (prev !== JSON.stringify(list)) {
306
- await saveBlob(slot.ref, { ...blob, usage: list, usageAt: Date.now() })
307
- } else if (!blob.usageAt) {
308
- await saveBlob(slot.ref, { ...blob, usageAt: Date.now() })
318
+ const list = Array.isArray(snap.windows) ? snap.windows : null
319
+ if (list) {
320
+ windows.set(slot.ref, list)
321
+ // persist last known windows so the card survives restarts
322
+ try {
323
+ const prev = Array.isArray(blob.usage) ? JSON.stringify(blob.usage) : ''
324
+ if (prev !== JSON.stringify(list)) {
325
+ await saveBlob(slot.ref, { ...blob, usage: list, usageAt: Date.now() })
326
+ } else if (!blob.usageAt) {
327
+ await saveBlob(slot.ref, { ...blob, usageAt: Date.now() })
328
+ }
329
+ } catch { /* persistence best-effort */ }
309
330
  }
310
- } catch { /* persistence best-effort */ }
311
- }
312
- } catch {
313
- usageFetched.set(slot.ref, Date.now())
331
+ } catch (err) {
332
+ usageFetched.set(slot.ref, Date.now())
333
+ const isTimeout = isTimeoutError(err)
334
+ if (isTimeout && !refreshFailures.has(slot.ref)) {
335
+ refreshFailures.set(slot.ref, { at: Date.now(), error: `Network timeout fetching usage: ${err.message || 'timed out'}`, isTimeout: true })
336
+ }
337
+ } finally {
338
+ slotUsageLocks.delete(slot.ref)
339
+ }
340
+ })()
341
+
342
+ slotUsageLocks.set(slot.ref, slotFlight)
343
+ await slotFlight.catch(() => {})
314
344
  }
315
- }
345
+ })().finally(() => {
346
+ providerUsageLocks.delete(provider)
347
+ })
348
+
349
+ providerUsageLocks.set(provider, flight)
350
+ return flight
316
351
  }
317
352
 
318
353
  return {
@@ -281,8 +281,9 @@ export function createAdapterManager({
281
281
  // #75: eager usage refresh at startup so the windows (5h/7d) land in the blob immediately
282
282
  const eager = async () => {
283
283
  try {
284
- for (const slot of normalizeSlots(live().slots)) {
285
- store.refreshUsage(slot.provider).catch((err) => {
284
+ const uniqueProviders = new Set(normalizeSlots(live().slots).map((s) => s.provider).filter(Boolean))
285
+ for (const provider of uniqueProviders) {
286
+ store.refreshUsage(provider).catch((err) => {
286
287
  if (logger && logger.debug) logger.debug('[dsh-subscriptions] eager usage error: ' + (err && err.message || err))
287
288
  })
288
289
  }
package/lib/adapter.js CHANGED
@@ -53,14 +53,16 @@ export class SubscriptionAdapter extends LlmAdapter {
53
53
  let models = null
54
54
  if (targetAccount) {
55
55
  const cfg = this.deps.vendorConfig(provider)
56
- const fetchImpl = pickFetch(this.deps, targetAccount.ref)
56
+ const baseFetch = pickFetch(this.deps, targetAccount.ref)
57
+ const listModelsFetch = (url, init, opts) =>
58
+ fetchWithTimeout(baseFetch, url, init, { timeoutMs: 15000, ...(opts || {}) })
57
59
  try {
58
60
  const blob = await this.deps.ensureFresh(
59
61
  provider,
60
62
  await this.deps.loadBlob(targetAccount.ref),
61
63
  targetAccount.ref,
62
64
  )
63
- const live = await getVendor(provider).listModels(blob, cfg, fetchImpl)
65
+ const live = await getVendor(provider).listModels(blob, cfg, listModelsFetch)
64
66
  if (Array.isArray(live) && live.length) models = live
65
67
  } catch { /* use built-in catalog */ }
66
68
  }
@@ -73,7 +75,7 @@ export class SubscriptionAdapter extends LlmAdapter {
73
75
  models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
74
76
  }
75
77
  // Defensive: ensure every model has the provider field set
76
- models = models.map((m) => (m && !m.provider ? { ...m, provider } : m))
78
+ models = models.map((m) => (m ? { ...m, provider: (!m.provider || m.provider === "custom") ? provider : m.provider } : m))
77
79
  return models
78
80
  }
79
81
 
@@ -136,12 +138,71 @@ export class SubscriptionAdapter extends LlmAdapter {
136
138
  let cacheReadTokens = 0
137
139
  let cacheWriteTokens = 0
138
140
  let approxChars = 0
139
- const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
141
+ let completed = false
142
+ let sawFinish = false
143
+ let finishReason = null
144
+ let errorCaught = null
145
+ let finalized = false
146
+ let blob = null
147
+
148
+ const finalizeOnce = (outcome, extraStatus) => {
149
+ if (finalized) return
150
+ finalized = true
151
+
152
+ if (!outputTokens && approxChars > 0) {
153
+ outputTokens = Math.max(1, Math.round(approxChars / 4))
154
+ }
155
+
156
+ const totalMs = Date.now() - t0
157
+ const ttftMs = firstChunkTime !== null ? Math.max(0, firstChunkTime - t0) : null
158
+ const genMs = (firstChunkTime !== null && totalMs > ttftMs) ? (totalMs - ttftMs) : 0
159
+ const tps = (outputTokens > 0 && genMs > 0)
160
+ ? Math.round((outputTokens / (genMs / 1000)) * 10) / 10
161
+ : null
162
+
163
+ let status = 200
164
+ if (outcome === 'error') {
165
+ status = extraStatus || (errorCaught && (errorCaught.status || errorCaught.statusCode)) || (errorCaught && errorCaught.code === 'QUOTA' ? 429 : 500)
166
+ } else if (outcome === 'cancel') {
167
+ status = 499
168
+ } else if (outcome === 'runaway') {
169
+ status = 200
170
+ }
171
+
172
+ if (outcome === 'success') {
173
+ if (typeof deps.recordSuccess === 'function') deps.recordSuccess(account.ref)
174
+ }
175
+
176
+ if (typeof deps.recordHistory === 'function') {
177
+ try {
178
+ deps.recordHistory({
179
+ provider,
180
+ ref: account.ref,
181
+ model: (opts && opts.model) || null,
182
+ path: '/responses',
183
+ method: 'POST',
184
+ status,
185
+ outcome,
186
+ ms: totalMs,
187
+ ttftMs,
188
+ tps,
189
+ inputTokens: inputTokens || undefined,
190
+ outputTokens: outputTokens || undefined,
191
+ cacheReadTokens: cacheReadTokens || undefined,
192
+ cacheWriteTokens: cacheWriteTokens || undefined,
193
+ kind: 'stream',
194
+ ...(outcome === 'error' && errorCaught ? { error: errorCaught.message || String(errorCaught) } : {}),
195
+ ...(outcome === 'runaway' ? { reason: finishReason || (opts && opts._runawayState && opts._runawayState.reason) || 'runaway_loop_detected' } : {}),
196
+ })
197
+ } catch { /* ignore quota capture failure */ }
198
+ }
199
+ }
200
+
140
201
  const vendor = getVendor(provider)
141
202
  // ponytail: capture x-ratelimit headers without touching vendors
142
203
  const baseFetch = pickFetch(deps, account.ref)
143
204
  const quotaFetch = async (url, init) => {
144
- const res = await fetchWithTimeout(baseFetch, url, init, { timeoutMs: 120000 })
205
+ const res = await fetchWithTimeout(baseFetch, url, init, { timeoutMs: 60000, stream: true })
145
206
  try {
146
207
  const snap = quotaSnapshot(provider, res.headers, null, Date.now())
147
208
  if (snap && typeof deps.rememberQuota === "function") {
@@ -162,7 +223,9 @@ export class SubscriptionAdapter extends LlmAdapter {
162
223
  } catch { /* ignore quota capture failure */ }
163
224
  if (!res.ok && res.clone) {
164
225
  try {
165
- const txt = await res.clone().text()
226
+ const textPromise = res.clone().text()
227
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000))
228
+ const txt = await Promise.race([textPromise, timeoutPromise])
166
229
  let j=null; try { j = JSON.parse(txt) } catch { j = null }
167
230
  if (j) {
168
231
  const b = parseBody(provider, j, Date.now())
@@ -189,7 +252,9 @@ export class SubscriptionAdapter extends LlmAdapter {
189
252
  }
190
253
  return res
191
254
  }
255
+
192
256
  try {
257
+ blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
193
258
  if (typeof deps.rememberRequest === 'function') deps.rememberRequest(account.ref)
194
259
  for await (const chunk of vendor.streamOnce({
195
260
  blob,
@@ -204,7 +269,10 @@ export class SubscriptionAdapter extends LlmAdapter {
204
269
  firstChunkTime = Date.now()
205
270
  }
206
271
  if (chunk && typeof chunk === 'object') {
207
- if (chunk.type === 'usage' && chunk.usage) {
272
+ if (chunk.type === 'finish') {
273
+ sawFinish = true
274
+ finishReason = chunk.reason
275
+ } else if (chunk.type === 'usage' && chunk.usage) {
208
276
  const u = toTokenUsage(chunk.usage)
209
277
  if (u && typeof u.outputTokens === 'number' && u.outputTokens > 0) {
210
278
  outputTokens = u.outputTokens
@@ -224,46 +292,15 @@ export class SubscriptionAdapter extends LlmAdapter {
224
292
  }
225
293
  yield chunk
226
294
  }
227
- if (!outputTokens && approxChars > 0) {
228
- outputTokens = Math.max(1, Math.round(approxChars / 4))
229
- }
230
- // stream completed successfully: clear cooldown and health penalties
231
- if (typeof deps.recordSuccess === 'function') deps.recordSuccess(account.ref)
232
- // #65: record in the request and cost history.
233
- if (typeof deps.recordHistory === 'function') {
234
- try {
235
- const totalMs = Date.now() - t0
236
- const ttftMs = firstChunkTime !== null ? Math.max(0, firstChunkTime - t0) : null
237
- const genMs = (firstChunkTime !== null && totalMs > ttftMs) ? (totalMs - ttftMs) : 0
238
- const tps = (outputTokens > 0 && genMs > 0)
239
- ? Math.round((outputTokens / (genMs / 1000)) * 10) / 10
240
- : null
241
-
242
- deps.recordHistory({
243
- provider,
244
- ref: account.ref,
245
- model: (opts && opts.model) || null,
246
- path: '/responses',
247
- method: 'POST',
248
- status: 200,
249
- ms: totalMs,
250
- ttftMs,
251
- tps,
252
- inputTokens: inputTokens || undefined,
253
- outputTokens: outputTokens || undefined,
254
- cacheReadTokens: cacheReadTokens || undefined,
255
- cacheWriteTokens: cacheWriteTokens || undefined,
256
- kind: 'stream',
257
- })
258
- } catch { /* ignore quota capture failure */ }
259
- }
295
+ completed = true
260
296
  } catch (err) {
297
+ errorCaught = err
261
298
  if (err && (err.code === 'QUOTA' || err.code === 'QUOTA_EXCEEDED' || err.status === 429)) {
262
299
  if (typeof deps.recordExhaust === 'function') deps.recordExhaust(account.ref)
263
300
  } else if (err && (err.status === 401 || err.status === 403 || err.code === 'TOKEN_REVOKED' || err.code === 'BROKEN')) {
264
301
  if (typeof deps.recordBroken === 'function') deps.recordBroken(account.ref)
265
302
  }
266
- if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
303
+ if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl && blob) {
267
304
  await deps.saveBlob(account.ref, {
268
305
  ...blob,
269
306
  validationUrl: err.validationUrl,
@@ -271,6 +308,18 @@ export class SubscriptionAdapter extends LlmAdapter {
271
308
  })
272
309
  }
273
310
  throw err
311
+ } finally {
312
+ if (errorCaught) {
313
+ finalizeOnce('error')
314
+ } else if (opts && opts._runawayState && opts._runawayState.tripped) {
315
+ finalizeOnce('runaway')
316
+ } else if (completed || sawFinish) {
317
+ finalizeOnce('success')
318
+ } else if (opts && opts.signal && opts.signal.aborted) {
319
+ finalizeOnce('cancel', 499)
320
+ } else {
321
+ finalizeOnce('cancel', 499)
322
+ }
274
323
  }
275
324
  },
276
325
  })) {
package/lib/backoff.js CHANGED
@@ -1,9 +1,21 @@
1
+ /**
2
+ * @template T
3
+ * @param {() => Promise<T>} fn
4
+ * @param {{
5
+ * maxRetries?: number,
6
+ * initialDelayMs?: number,
7
+ * maxDelayMs?: number,
8
+ * factor?: number,
9
+ * isRetryable?: (err: any) => boolean,
10
+ * }} [options]
11
+ * @returns {Promise<T>}
12
+ */
1
13
  export async function executeWithRetry(fn, {
2
14
  maxRetries = 3,
3
15
  initialDelayMs = 200,
4
16
  maxDelayMs = 3000,
5
17
  factor = 2,
6
- isRetryable = () => true
18
+ isRetryable = (_err) => true
7
19
  } = {}) {
8
20
  let attempt = 0
9
21
  let delay = initialDelayMs
package/lib/client.js CHANGED
@@ -919,9 +919,12 @@ const cssId = 'dsh-subscriptions/settings.module.css'
919
919
  const items = (h && h.items) || []
920
920
  const events = items.map((it) => ({
921
921
  usage: {
922
- promptTokens: it.promptTokens || (it.tokens && it.tokens.prompt) || 0,
923
- cachedTokens: it.cachedTokens || (it.tokens && it.tokens.cached) || 0,
924
- completionTokens: it.completionTokens || (it.tokens && it.tokens.completion) || 0,
922
+ inputTokens: it.inputTokens ?? it.promptTokens ?? (it.tokens && it.tokens.prompt) ?? 0,
923
+ cacheReadTokens: it.cacheReadTokens ?? it.cachedTokens ?? (it.tokens && it.tokens.cached) ?? 0,
924
+ outputTokens: it.outputTokens ?? it.completionTokens ?? (it.tokens && it.tokens.completion) ?? 0,
925
+ promptTokens: it.inputTokens ?? it.promptTokens ?? (it.tokens && it.tokens.prompt) ?? 0,
926
+ cachedTokens: it.cacheReadTokens ?? it.cachedTokens ?? (it.tokens && it.tokens.cached) ?? 0,
927
+ completionTokens: it.outputTokens ?? it.completionTokens ?? (it.tokens && it.tokens.completion) ?? 0,
925
928
  },
926
929
  }))
927
930
  return fetch('/dsh-subscriptions/analyze-session', {
@@ -1396,6 +1399,10 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1396
1399
  React.createElement('div', { className: 'dsub-stat-val' }, (telemetry.successRate || 100) + '%'),
1397
1400
  React.createElement('div', { className: 'dsub-stat-lbl' }, t('statsSuccessRate')),
1398
1401
  ),
1402
+ React.createElement('div', { className: 'dsub-stat-box' },
1403
+ React.createElement('div', { className: 'dsub-stat-val' }, (telemetry.cacheHitPercent || 0) + '%'),
1404
+ React.createElement('div', { className: 'dsub-stat-lbl' }, t('cacheHitRate')),
1405
+ ),
1399
1406
  React.createElement('div', { className: 'dsub-stat-box' },
1400
1407
  React.createElement('div', { className: 'dsub-stat-val' }, telemetry.lastRequestAt ? Math.round((Date.now() - telemetry.lastRequestAt) / 60000) + 'm ago' : '—'),
1401
1408
  React.createElement('div', { className: 'dsub-stat-lbl' }, t('statsLastActivity')),
@@ -2169,17 +2176,31 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2169
2176
  ))
2170
2177
  if (typeof un1 === 'function') cleanups.push(un1)
2171
2178
 
2172
- const un2 = ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
2179
+ // DSH 0.2 bundle config seat: rendered in PackageDetail when viewing package
2180
+ const unBundle = ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register(
2173
2181
  {
2174
- name: 'plugins.row.config',
2175
- key: ROW_CONFIG_KEY,
2182
+ name: 'plugins.bundle.config',
2183
+ key: PKG,
2176
2184
  locale: NS,
2177
2185
  inject: () => ({ ctx: ctx }),
2178
2186
  },
2179
2187
  PluginCard,
2180
2188
  ))
2181
- if (typeof un2 === 'function') cleanups.push(un2)
2182
-
2189
+ if (typeof unBundle === 'function') cleanups.push(unBundle)
2190
+
2191
+ // DSH 0.1/0.2 row config seats
2192
+ for (const key of [ROW_CONFIG_KEY, ROW_ID + '#' + ROW_ID, ROW_ID]) {
2193
+ const unRow = ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
2194
+ {
2195
+ name: 'plugins.row.config',
2196
+ key,
2197
+ locale: NS,
2198
+ inject: () => ({ ctx: ctx }),
2199
+ },
2200
+ PluginCard,
2201
+ ))
2202
+ if (typeof unRow === 'function') cleanups.push(unRow)
2203
+ }
2183
2204
  }
2184
2205
  } catch {
2185
2206
  // Slot system not available — expected on headless / non-web profiles.
@@ -2536,8 +2557,35 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2536
2557
  e.preventDefault()
2537
2558
  setState((s) => Object.assign({}, s, { open: false }))
2538
2559
  try {
2539
- const btn = Array.from(document.querySelectorAll('button')).find((b) => (b.textContent || '').includes(t('title')))
2540
- if (btn) btn.click()
2560
+ const nav = (props.ctx && props.ctx.pluginNavigation) || (window.__dshCtx && window.__dshCtx.pluginNavigation)
2561
+ if (nav && typeof nav.openBundle === 'function') {
2562
+ nav.openBundle(PKG)
2563
+ return
2564
+ }
2565
+ } catch { /* best-effort */ }
2566
+
2567
+ try {
2568
+ if (props.ctx && props.ctx.layout && typeof props.ctx.layout.selectPanel === 'function') {
2569
+ props.ctx.layout.selectPanel('plugins')
2570
+ }
2571
+ } catch { /* best-effort */ }
2572
+
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)
2541
2589
  } catch { /* best-effort */ }
2542
2590
  },
2543
2591
  }, t('subsOpenSettings')),
@@ -2557,7 +2605,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2557
2605
  order: 15,
2558
2606
  locale: NS,
2559
2607
  },
2560
- () => React.createElement(ErrorBoundary, null, React.createElement(SubsPill, { t })),
2608
+ () => React.createElement(ErrorBoundary, null, React.createElement(SubsPill, { t, ctx })),
2561
2609
  ),
2562
2610
  )
2563
2611
  }, 'dsh-subscriptions: subs pill')
@@ -2643,6 +2691,11 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2643
2691
  exports.inject = ['slots', 'locale', 'sessions']
2644
2692
  exports.apply = function apply(ctx) {
2645
2693
  setT(ctx.locale.bind(NS))
2694
+ try {
2695
+ ctx.inject(['pluginNavigation'], (navCtx) => {
2696
+ ctx.pluginNavigation = navCtx.pluginNavigation
2697
+ })
2698
+ } catch { /* optional */ }
2646
2699
  registerSettings(ctx)
2647
2700
  registerSubsPill(ctx)
2648
2701
  registerComposerQuota(ctx)
@@ -204,6 +204,7 @@ export function streamEnvelope({ projectId, model, request, userAgent, sessionId
204
204
  const session = sessionId || randomUUID()
205
205
  const body = { ...(request || {}) }
206
206
  if (!body.session_id) body.session_id = session
207
+ /** @type {Record<string, any>} */
207
208
  const envelope = {
208
209
  ...(projectId ? { project: projectId } : {}),
209
210
  model,
@@ -98,7 +98,7 @@ export function validationFromHttpError(status, bodyText) {
98
98
  }
99
99
 
100
100
  export function validationRequiredError(details) {
101
- const err = new Error(`${details.message} Verify at ${details.validationUrl}`)
101
+ const err = /** @type {Error & { code?: string, validationUrl?: string, learnMoreUrl?: string }} */ (new Error(`${details.message} Verify at ${details.validationUrl}`))
102
102
  err.code = 'VALIDATION_REQUIRED'
103
103
  err.validationUrl = details.validationUrl
104
104
  if (details.learnMoreUrl) err.learnMoreUrl = details.learnMoreUrl
package/lib/history.js CHANGED
@@ -242,6 +242,9 @@ export class HistoryStore {
242
242
  const now = Date.now()
243
243
  const last24h = now - 24 * 60 * 60 * 1000
244
244
  let count24h = 0
245
+ let totalPromptTokens = 0
246
+ let totalCompletionTokens = 0
247
+ let totalCacheReadTokens = 0
245
248
 
246
249
  for (const r of this.rows) {
247
250
  if (r.ts >= last24h) count24h++
@@ -252,6 +255,9 @@ export class HistoryStore {
252
255
  totalMs += r.ms
253
256
  latencyCount++
254
257
  }
258
+ if (typeof r.inputTokens === 'number') totalPromptTokens += r.inputTokens
259
+ if (typeof r.outputTokens === 'number') totalCompletionTokens += r.outputTokens
260
+ if (typeof r.cacheReadTokens === 'number') totalCacheReadTokens += r.cacheReadTokens
255
261
  }
256
262
 
257
263
  const first = this.rows[0] // newest first
@@ -266,6 +272,12 @@ export class HistoryStore {
266
272
  lastRequestAt: (first && first.ts) || null,
267
273
  lastProvider: (first && first.provider) || null,
268
274
  lastModel: (first && first.model) || null,
275
+ totalPromptTokens,
276
+ totalCompletionTokens,
277
+ totalCacheReadTokens,
278
+ cacheHitPercent: totalPromptTokens > 0
279
+ ? Math.round((totalCacheReadTokens / totalPromptTokens) * 1000) / 10
280
+ : 0,
269
281
  accounts: this.accountsTelemetry(),
270
282
  }
271
283
  }