@goodandready/dsh-subscriptions 0.6.37 → 0.6.39

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 { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure, STATUS_QUARANTINE, STATUS_PROBING } from "./quarantine.js"
1
2
  import { fetchWithTimeout } from "./http.js"
2
3
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
3
4
  import { oauthRef, isProvider } from './refs.js'
@@ -13,7 +14,6 @@ export function normalizeSlots(slots) {
13
14
  const out = []
14
15
  const seen = new Set()
15
16
  for (const slot of Array.isArray(slots) ? slots : []) {
16
- // User-editable config: a null or non-object entry must be skipped, not crash the plugin.
17
17
  if (!slot || typeof slot !== 'object') continue
18
18
  if (!isProvider(slot.provider)) continue
19
19
  const index = Number(slot.index)
@@ -22,10 +22,13 @@ export function normalizeSlots(slots) {
22
22
  if (seen.has(ref)) continue
23
23
  seen.add(ref)
24
24
  out.push({
25
+ ...slot,
25
26
  provider: slot.provider,
26
27
  index,
27
28
  label: String(slot.label || ''),
28
29
  ref,
30
+ ...(slot.proxyUrl ? { proxyUrl: String(slot.proxyUrl) } : {}),
31
+ ...(slot.expiresAt ? { expiresAt: Number(slot.expiresAt) } : {}),
29
32
  })
30
33
  }
31
34
  return out
@@ -94,16 +97,27 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
94
97
  }
95
98
 
96
99
  async function saveBlob(ref, blob) {
100
+ if (!ref || typeof ref !== "string") throw new Error("invalid ref for saveBlob")
97
101
  const prev = writeQueues.get(ref) || Promise.resolve()
98
102
  const next = prev.catch(() => {}).then(async () => {
99
- await credentials.set(credentialRef(ref), serializeBlob(blob))
103
+ try {
104
+ await credentials.set(credentialRef(ref), serializeBlob(blob))
105
+ } catch (err) {
106
+ const wrapErr = new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`)
107
+ wrapErr.code = "CREDENTIAL_SAVE_FAILED"
108
+ wrapErr.cause = err
109
+ throw wrapErr
110
+ }
100
111
  })
101
112
  writeQueues.set(ref, next)
102
113
  await next
103
114
  }
104
115
 
105
116
  async function clearRef(ref) {
106
- await credentials.unset(credentialRef(ref))
117
+ if (!ref || typeof ref !== "string") return
118
+ try {
119
+ await credentials.unset(credentialRef(ref))
120
+ } catch { /* best-effort unset */ }
107
121
  cooldowns.delete(ref)
108
122
  quotas.delete(ref)
109
123
  quarantines.delete(ref)
@@ -202,6 +216,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
202
216
  }
203
217
 
204
218
  async function ensureFresh(provider, blob, ref) {
219
+ if (!blob || typeof blob !== 'object') return blob
220
+ if (blob.apiKeyOnly || (blob.apiKey && (!blob.refreshToken || blob.refreshToken === blob.apiKey))) return blob
205
221
  if (!blob.refreshToken) return blob
206
222
  if (blob.expiresAt && blob.expiresAt - SKEW_MS > Date.now()) return blob
207
223
  const lockKey = ref || (provider + ":" + (blob.accountId || blob.email || (blob.refreshToken && blob.refreshToken.slice(-16)) || "anon"))
@@ -333,19 +349,51 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
333
349
  quarantines.delete(ref)
334
350
  if (health.has(ref)) health.set(ref, null)
335
351
  },
336
- rememberQuarantine(ref, reason, until) {
352
+ rememberQuarantine(ref, reason, until, attempts = 1) {
337
353
  if (!ref) return
338
- quarantines.set(ref, { reason: reason || 'RATE_LIMIT', until: Number(until) || (Date.now() + 3600000) })
354
+ quarantines.set(ref, {
355
+ reason: reason || 'RATE_LIMIT',
356
+ until: Number(until) || (Date.now() + 3600000),
357
+ attempts: Number(attempts) || 1,
358
+ status: STATUS_QUARANTINE,
359
+ })
339
360
  },
340
- getQuarantine(ref) {
361
+ getQuarantine(ref, now = Date.now()) {
341
362
  const q = quarantines.get(ref)
342
363
  if (!q) return null
343
- if (Number(q.until) <= Date.now()) {
364
+ if (Number(q.until) <= now) {
365
+ if (q.status === STATUS_PROBING) return q
344
366
  quarantines.delete(ref)
345
367
  return null
346
368
  }
347
369
  return q
348
370
  },
371
+ async probeWarmup(ref, probeFn) {
372
+ const q = quarantines.get(ref)
373
+ if (!q) return { success: true }
374
+ const acc = { quarantineReason: q.reason, quarantineUntil: q.until, quarantineAttempts: q.attempts || 1, status: q.status }
375
+ if (!needsWarmupProbe(acc)) {
376
+ return { success: false, reason: 'NOT_EXPIRED' }
377
+ }
378
+ quarantines.set(ref, enterProbing(acc))
379
+ try {
380
+ if (typeof probeFn === 'function') {
381
+ await probeFn(ref)
382
+ }
383
+ resolveWarmupSuccess(acc)
384
+ quarantines.delete(ref)
385
+ return { success: true }
386
+ } catch (err) {
387
+ const updated = resolveWarmupFailure(acc, err?.message || q.reason)
388
+ quarantines.set(ref, {
389
+ reason: updated.quarantineReason,
390
+ until: updated.quarantineUntil,
391
+ attempts: updated.quarantineAttempts,
392
+ status: updated.status,
393
+ })
394
+ return { success: false, account: updated, error: err }
395
+ }
396
+ },
349
397
  releaseQuarantine(ref) {
350
398
  quarantines.delete(ref)
351
399
  },
@@ -21,6 +21,7 @@ export function createAdapterManager({
21
21
  }) {
22
22
  let handle
23
23
  let adapterDisposed = false
24
+ let retryTimer
24
25
  let registeredByUs = new Set()
25
26
 
26
27
  function disposeAdapterHandle(h) {
@@ -48,6 +49,11 @@ export function createAdapterManager({
48
49
  saveBlob: (ref, blob) => store.saveBlob(ref, blob),
49
50
  alertThresholds: () => live().alertThresholds,
50
51
  webhookUrl: () => live().webhookUrl,
52
+ rememberRequest: (ref) => store.rememberRequest(ref),
53
+ recordSuccess: (ref) => store.recordSuccess(ref),
54
+ recordSwitch: (ref) => store.recordSwitch(ref),
55
+ recordExhaust: (ref) => store.recordExhaust(ref),
56
+ recordBroken: (ref) => store.recordBroken(ref),
51
57
  recordHistory,
52
58
  fetchImpl: fetch,
53
59
  fetchForRef,
@@ -83,6 +89,7 @@ export function createAdapterManager({
83
89
  async function syncAdapter() {
84
90
  if (adapterDisposed) return
85
91
  const desired = await store.loggedInProviders()
92
+ if (adapterDisposed) return
86
93
  const globalProviders = ctx.llm && typeof ctx.llm.listProviders === 'function'
87
94
  ? ctx.llm.listProviders().map((p) => p.id)
88
95
  : []
@@ -95,6 +102,7 @@ export function createAdapterManager({
95
102
  logger.info(`[dsh-subscriptions] syncAdapter skipping already-bound providers: ${skipped.join(', ')}`)
96
103
  }
97
104
 
105
+ if (adapterDisposed) return
98
106
  if (!handle && claimable.length) {
99
107
  handle = ctx.llm.registerAdapter(claimable, adapter)
100
108
  registeredByUs = new Set(claimable)
@@ -202,6 +210,8 @@ export function createAdapterManager({
202
210
  }
203
211
 
204
212
  let lastModelRefresh = 0
213
+ const providerModelCache = new Map()
214
+
205
215
  async function refreshModels() {
206
216
  if (Date.now() - lastModelRefresh < 60 * 1000) return
207
217
  lastModelRefresh = Date.now()
@@ -214,7 +224,10 @@ export function createAdapterManager({
214
224
  if (!blob) continue
215
225
  const fresh = await store.ensureFresh(provider, blob, slot.ref)
216
226
  const cfg = vendorConfig(provider, live())
217
- await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => {})
227
+ const liveModels = await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => null)
228
+ if (Array.isArray(liveModels) && liveModels.length) {
229
+ providerModelCache.set(provider, liveModels)
230
+ }
218
231
  } catch { /* best-effort */ }
219
232
  }
220
233
  }
@@ -234,7 +247,6 @@ export function createAdapterManager({
234
247
  })
235
248
 
236
249
  let retries = 0
237
- let retryTimer
238
250
  const scheduleRetry = () => {
239
251
  if (adapterDisposed || retries >= 30) return
240
252
  retryTimer = setTimeout(retrySync, 1000)
@@ -298,5 +310,15 @@ export function createAdapterManager({
298
310
  refreshModels,
299
311
  cascadeFallbackStream,
300
312
  registerAdapterLifecycle,
313
+ dispose: () => {
314
+ adapterDisposed = true
315
+ adapter.dispose()
316
+ if (retryTimer) clearTimeout(retryTimer)
317
+ if (handle) {
318
+ disposeAdapterHandle(handle)
319
+ handle = undefined
320
+ registeredByUs.clear()
321
+ }
322
+ },
301
323
  }
302
324
  }
package/lib/adapter.js CHANGED
@@ -32,6 +32,11 @@ export class SubscriptionAdapter extends LlmAdapter {
32
32
  constructor(deps) {
33
33
  super()
34
34
  this.deps = deps
35
+ this.disposed = false
36
+ }
37
+
38
+ dispose() {
39
+ this.disposed = true
35
40
  }
36
41
 
37
42
  providerInfo(provider) {
@@ -45,20 +50,24 @@ export class SubscriptionAdapter extends LlmAdapter {
45
50
  async listModels(provider) {
46
51
  const accounts = await this.deps.listAccounts(provider)
47
52
  const targetAccount = accounts.find((a) => a.hasToken)
48
- if (!targetAccount) return []
49
- const cfg = this.deps.vendorConfig(provider)
50
- const fetchImpl = pickFetch(this.deps, targetAccount.ref)
51
53
  let models = null
52
- try {
53
- const blob = await this.deps.ensureFresh(
54
- provider,
55
- await this.deps.loadBlob(targetAccount.ref),
56
- targetAccount.ref,
57
- )
58
- const live = await getVendor(provider).listModels(blob, cfg, fetchImpl)
59
- if (Array.isArray(live) && live.length) models = live
60
- } catch { /* use built-in catalog */ }
61
- if (!models) models = modelCatalog(provider, cfg.models)
54
+ if (targetAccount) {
55
+ const cfg = this.deps.vendorConfig(provider)
56
+ const fetchImpl = pickFetch(this.deps, targetAccount.ref)
57
+ try {
58
+ const blob = await this.deps.ensureFresh(
59
+ provider,
60
+ await this.deps.loadBlob(targetAccount.ref),
61
+ targetAccount.ref,
62
+ )
63
+ const live = await getVendor(provider).listModels(blob, cfg, fetchImpl)
64
+ if (Array.isArray(live) && live.length) models = live
65
+ } catch { /* use built-in catalog */ }
66
+ }
67
+ if (!models) {
68
+ const cfg = this.deps.vendorConfig(provider)
69
+ models = modelCatalog(provider, cfg ? cfg.models : undefined)
70
+ }
62
71
  // #94: optionally hide test/preview/beta/legacy ids from the picker.
63
72
  if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
64
73
  models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
@@ -234,6 +243,11 @@ export class SubscriptionAdapter extends LlmAdapter {
234
243
  } catch { /* ignore quota capture failure */ }
235
244
  }
236
245
  } catch (err) {
246
+ if (err && (err.code === 'QUOTA' || err.code === 'QUOTA_EXCEEDED' || err.status === 429)) {
247
+ if (typeof deps.recordExhaust === 'function') deps.recordExhaust(account.ref)
248
+ } else if (err && (err.status === 401 || err.status === 403 || err.code === 'TOKEN_REVOKED' || err.code === 'BROKEN')) {
249
+ if (typeof deps.recordBroken === 'function') deps.recordBroken(account.ref)
250
+ }
237
251
  if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
238
252
  await deps.saveBlob(account.ref, {
239
253
  ...blob,
@@ -271,7 +285,7 @@ export class SubscriptionAdapter extends LlmAdapter {
271
285
  }
272
286
  // #91: when the whole pool is exhausted and nothing has been streamed
273
287
  // yet, continue seamlessly on local Ollama instead of failing the chat.
274
- if (!yieldedAny && typeof deps.ollamaFallback === 'function') {
288
+ if (!yieldedAny && !options.signal?.aborted && typeof deps.ollamaFallback === 'function') {
275
289
  yield* deps.ollamaFallback({ options, provider, err })
276
290
  return
277
291
  }
package/lib/blob.js CHANGED
@@ -1,30 +1,33 @@
1
1
  function asString(value) {
2
- return value == null ? '' : String(value)
2
+ return value == null ? "" : String(value)
3
3
  }
4
4
 
5
5
  export function serializeBlob(obj) {
6
- const accessToken = asString(obj && obj.accessToken)
7
- const refreshToken = asString(obj && obj.refreshToken)
8
- if (!accessToken && !refreshToken) {
9
- throw new Error('oauth blob needs accessToken or refreshToken')
6
+ if (!obj || typeof obj !== "object") throw new Error("invalid oauth blob")
7
+ const accessToken = asString(obj.accessToken)
8
+ const refreshToken = asString(obj.refreshToken)
9
+ if (!accessToken && !refreshToken && !obj.apiKey) {
10
+ throw new Error("oauth blob needs accessToken or refreshToken")
10
11
  }
11
12
  return JSON.stringify({
13
+ ...obj,
12
14
  accessToken,
13
15
  refreshToken,
14
- expiresAt: Number(obj && obj.expiresAt) || 0,
15
- label: asString(obj && obj.label),
16
- email: asString(obj && obj.email),
17
- accountId: asString(obj && obj.accountId),
18
- projectId: asString(obj && obj.projectId),
19
- ...(Array.isArray(obj && obj.usage) ? { usage: obj.usage } : {}),
20
- ...((obj && obj.usageAt) ? { usageAt: Number(obj.usageAt) } : {}),
16
+ expiresAt: Number(obj.expiresAt) || 0,
17
+ label: asString(obj.label),
18
+ email: asString(obj.email),
19
+ accountId: asString(obj.accountId),
20
+ projectId: asString(obj.projectId),
21
+ ...(Array.isArray(obj.usage) ? { usage: obj.usage } : {}),
22
+ ...(obj.usageAt ? { usageAt: Number(obj.usageAt) } : {}),
21
23
  })
22
24
  }
23
25
 
24
26
  export function parseBlob(text) {
25
- const obj = typeof text === 'string' ? JSON.parse(text) : text
26
- if (!obj || typeof obj !== 'object') throw new Error('invalid oauth blob')
27
+ const obj = typeof text === "string" ? JSON.parse(text) : text
28
+ if (!obj || typeof obj !== "object") throw new Error("invalid oauth blob")
27
29
  return {
30
+ ...obj,
28
31
  accessToken: asString(obj.accessToken),
29
32
  refreshToken: asString(obj.refreshToken),
30
33
  expiresAt: Number(obj.expiresAt) || 0,
@@ -36,4 +39,3 @@ export function parseBlob(text) {
36
39
  ...(obj.usageAt ? { usageAt: Number(obj.usageAt) } : {}),
37
40
  }
38
41
  }
39
-
package/lib/cascade.js CHANGED
@@ -9,6 +9,16 @@ export const DEFAULT_CASCADE_CHAINS = {
9
9
  copilot: ['cursor', 'codex'],
10
10
  cursor: ['copilot', 'claude'],
11
11
  antigravity: ['copilot', 'claude'],
12
+ kimi: ['qwen', 'glm', 'spark'],
13
+ glm: ['kimi', 'qwen', 'spark'],
14
+ qwen: ['kimi', 'glm', 'spark'],
15
+ spark: ['qwen', 'kimi', 'glm'],
16
+ jetbrains: ['copilot', 'cursor'],
17
+ perplexity: ['copilot', 'cursor'],
18
+ replit: ['copilot', 'cursor'],
19
+ cody: ['copilot', 'cursor'],
20
+ kiro: ['copilot', 'cursor'],
21
+ ernie: ['qwen', 'glm'],
12
22
  }
13
23
 
14
24
  /**
@@ -149,3 +149,55 @@ export function publicConfig(cfg) {
149
149
 
150
150
  return clone
151
151
  }
152
+
153
+ export function restoreMaskedSecrets(incoming, existing) {
154
+ if (!incoming || typeof incoming !== "object") return incoming
155
+ if (!existing || typeof existing !== "object") return incoming
156
+
157
+ const out = { ...incoming }
158
+
159
+ for (const key of Object.keys(out)) {
160
+ if ((key.endsWith("ClientSecret") || key.endsWith("Secret")) && typeof out[key] === "string") {
161
+ if (out[key].includes("••••")) {
162
+ out[key] = existing[key] || ""
163
+ }
164
+ }
165
+ }
166
+
167
+ if (Array.isArray(out.slots) && Array.isArray(existing.slots)) {
168
+ out.slots = out.slots.map((s) => {
169
+ if (!s || typeof s !== "object" || typeof s.proxyUrl !== "string" || !s.proxyUrl.includes("••••")) return s
170
+ const match = existing.slots.find((e) => e && e.provider === s.provider && Number(e.index) === Number(s.index))
171
+ if (match && match.proxyUrl) {
172
+ return { ...s, proxyUrl: match.proxyUrl }
173
+ }
174
+ return s
175
+ })
176
+ }
177
+
178
+ if (Array.isArray(out.customVendors) && Array.isArray(existing.customVendors)) {
179
+ out.customVendors = out.customVendors.map((cv) => {
180
+ if (!cv || typeof cv !== "object") return cv
181
+ const match = existing.customVendors.find((e) => e && e.id === cv.id)
182
+ if (!match) return cv
183
+ const copy = { ...cv }
184
+ for (const f of ["apiKey", "token", "secret", "clientSecret"]) {
185
+ if (typeof copy[f] === "string" && copy[f].includes("••••")) {
186
+ copy[f] = match[f] || ""
187
+ }
188
+ }
189
+ if (copy.headers && typeof copy.headers === "object" && match.headers && typeof match.headers === "object") {
190
+ const h = { ...copy.headers }
191
+ for (const hk of Object.keys(h)) {
192
+ if (typeof h[hk] === "string" && h[hk].includes("••••")) {
193
+ h[hk] = match.headers[hk] || ""
194
+ }
195
+ }
196
+ copy.headers = h
197
+ }
198
+ return copy
199
+ })
200
+ }
201
+
202
+ return out
203
+ }
package/lib/rotate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { computePacingRisk, calculateBurnRatePerHour } from "./forecast.js"
1
2
  import { isQuarantined } from './quarantine.js'
2
3
  import { getPinnedAccountRef, pinSession } from './session-pin.js'
3
4
 
@@ -43,7 +44,7 @@ export function cooldownBlocks(acc, family) {
43
44
  export function pickAccount(accounts, nowMs, opts) {
44
45
  const list = Array.isArray(accounts) ? accounts : []
45
46
  const now = Number(nowMs) || 0
46
- const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0
47
+ const thrRaw = opts && opts.switchAtRemaining != null ? Number(opts.switchAtRemaining) : 0.01
47
48
  const thr = Number.isFinite(thrRaw) ? thrRaw : 0
48
49
  const isVipRequest = Boolean(opts && opts.vip)
49
50
  const targetTag = opts && opts.tag
@@ -92,11 +93,20 @@ export function pickAccount(accounts, nowMs, opts) {
92
93
  // Tier 1: Quota unknown (null) & healthy
93
94
  // Tier 2: Cooldown, quarantine or exhausted
94
95
  const tiers = [[], [], []]
95
- const fallback = []
96
96
 
97
97
  for (const acc of list) {
98
98
  if (!acc || !acc.hasToken) continue
99
99
 
100
+ if (!opts || opts.autoPacing !== false) {
101
+ if (acc.pacingRisk === undefined) {
102
+ const rem = acc.quota?.remainingPercent ?? (acc.usagePercent != null ? (100 - Number(acc.usagePercent)) : null)
103
+ const pace = acc.pacePerHour || (Array.isArray(acc.samples) ? calculateBurnRatePerHour(acc.samples, now) : 0)
104
+ const resetAt = acc.quota?.resetAt || 0
105
+ const riskInfo = computePacingRisk(rem, pace, resetAt, now)
106
+ acc.pacingRisk = riskInfo.pacingRisk
107
+ }
108
+ }
109
+
100
110
  // VIP Slot Reservation (#175)
101
111
  if (acc.vipOnly && !isVipRequest) continue
102
112
 
@@ -105,11 +115,16 @@ export function pickAccount(accounts, nowMs, opts) {
105
115
 
106
116
  const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
107
117
  const inQuarantine = isQuarantined(acc, now)
108
- const exhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
118
+ const isHardExhausted = (acc.quota && acc.quota.remaining != null && Number(acc.quota.remaining) <= 0 && acc.quota.resetAt && Number(acc.quota.resetAt) > now) ||
119
+ (acc.usagePercent != null && Number(acc.usagePercent) >= 100 && (!acc.quota || !acc.quota.resetAt || Number(acc.quota.resetAt) > now))
120
+
121
+ if (isCooldown || inQuarantine || isHardExhausted) {
122
+ continue
123
+ }
109
124
 
110
- if (isCooldown || inQuarantine || exhausted) {
125
+ const softExhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
126
+ if (softExhausted) {
111
127
  tiers[2].push(acc)
112
- fallback.push(acc)
113
128
  continue
114
129
  }
115
130
 
@@ -140,7 +155,7 @@ export function pickAccount(accounts, nowMs, opts) {
140
155
  if (aWeight !== bWeight) return bWeight - aWeight
141
156
 
142
157
  // 3. Health Score
143
- return (b.healthScore || 100) - (a.healthScore || 100)
158
+ return (b.healthScore ?? 100) - (a.healthScore ?? 100)
144
159
  })
145
160
 
146
161
  const chosen = tier[0]
@@ -151,7 +166,6 @@ export function pickAccount(accounts, nowMs, opts) {
151
166
  }
152
167
  }
153
168
 
154
- if (fallback.length) return fallback[0]
155
169
  return null
156
170
  }
157
171
 
@@ -1,7 +1,7 @@
1
1
  // #374: Sensitive GET routes protected by same-origin check
2
2
  import { getRecentAlerts, clearAlerts } from '../alerts.js'
3
3
  import { normalizeSlots, vendorConfig } from '../accounts.js'
4
- import { Config, plainConfig, publicConfig } from '../config-schema.js'
4
+ import { Config, plainConfig, publicConfig, restoreMaskedSecrets } from '../config-schema.js'
5
5
  import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
6
6
  import { quotaSnapshot } from '../ratelimit.js'
7
7
  import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
@@ -65,7 +65,8 @@ export function registerStatusRoutes(ctx, state) {
65
65
  return
66
66
  }
67
67
  // #379: revision conflict detection (CAS)
68
- const baseRev = payload?.revision != null ? Number(payload.revision) : (req.headers?.['if-match'] ? Number(req.headers['if-match']) : null)
68
+ const rawRev = payload?.revision ?? payload?.config?.revision ?? req.headers?.['if-match']
69
+ const baseRev = rawRev != null ? Number(rawRev) : null
69
70
  if (baseRev != null && !Number.isNaN(baseRev) && baseRev !== currentRevision) {
70
71
  writeJson(res, 409, {
71
72
  ok: false,
@@ -80,13 +81,16 @@ export function registerStatusRoutes(ctx, state) {
80
81
 
81
82
  if (payload && typeof payload.config === 'object') payload = payload.config
82
83
  try {
84
+ payload = restoreMaskedSecrets(payload, live())
83
85
  if (Array.isArray(payload.slots)) payload.slots = stripLegacySlots(payload.slots)
84
86
  const parsed = plainConfig(Config(payload))
85
87
  const dropped = droppedCredentialRefs(live().slots, parsed.slots)
86
88
  await getSettingsApi().replace(parsed)
87
89
  currentRevision++
88
90
  syncCustomVendors()
89
- for (const ref of dropped) await store.clearRef(ref)
91
+ for (const ref of dropped) {
92
+ try { await store.clearRef(ref) } catch { /* best-effort cleanup */ }
93
+ }
90
94
  await syncAdapter()
91
95
  writeJson(res, 200, await configResponse())
92
96
  } catch (e) {
@@ -1,3 +1,4 @@
1
+ import { fetchWithTimeout } from '../http.js'
1
2
  import { buildAuthorizeUrl } from '../oauth.js'
2
3
  import { anthropicPayload, modelCatalog } from '../messages.js'
3
4
  import { jsonTokenRequest, anthropicStream, httpError, tokenBlobFromOAuth, readJson } from '../wire.js'
@@ -119,15 +120,50 @@ export async function refresh(cfg, blob, fetchImpl) {
119
120
  return decorate(tokenBlobFromOAuth(json, { label: blob.label, email: blob.email }), fetchImpl)
120
121
  }
121
122
 
122
- export async function listModels(blob, cfg) {
123
- const raw = cfg.models && cfg.models.length ? cfg.models : defaults().models
124
- const entries = raw.map((entry) => {
125
- const model = typeof entry === 'string' ? { id: entry, name: entry } : { ...entry }
123
+ export async function listModels(blob, cfg, fetchImpl) {
124
+ const impl = fetchImpl || fetch
125
+ const rawDefault = (cfg && cfg.models && cfg.models.length) ? cfg.models : defaults().models
126
+ const defaultCatalog = modelCatalog(id, rawDefault.map((entry) => {
127
+ const model = typeof entry === "string" ? { id: entry, name: entry } : { ...entry }
126
128
  const levels = effortLevelsForModel(model.id || model.name)
127
129
  if (!levels) return model
128
130
  return { ...model, reasoning: { efforts: levels.map((level) => ({ id: level, name: level })) } }
129
- })
130
- return modelCatalog(id, entries)
131
+ }))
132
+
133
+ if (!blob || !blob.accessToken) return defaultCatalog
134
+
135
+ try {
136
+ const headers = {
137
+ "Accept": "application/json",
138
+ "anthropic-version": "2023-06-01",
139
+ }
140
+ if (blob.accessToken.startsWith("sk-ant-")) {
141
+ headers["x-api-key"] = blob.accessToken
142
+ } else {
143
+ headers["Authorization"] = `Bearer ${blob.accessToken}`
144
+ }
145
+
146
+ const res = await fetchWithTimeout(impl, "https://api.anthropic.com/v1/models", { headers }, { timeoutMs: 15000 })
147
+ if (!res.ok) return defaultCatalog
148
+ const json = await readJson(res)
149
+ const items = json && json.data
150
+ if (!Array.isArray(items) || !items.length) return defaultCatalog
151
+ const rows = []
152
+ for (const item of items) {
153
+ const modelId = item.id || item.display_name
154
+ if (!modelId) continue
155
+ const levels = effortLevelsForModel(modelId)
156
+ rows.push({
157
+ id: modelId,
158
+ name: item.display_name || modelId,
159
+ ...(levels ? { reasoning: { efforts: levels.map((level) => ({ id: level, name: level })) } } : {}),
160
+ })
161
+ }
162
+ if (!rows.length) return defaultCatalog
163
+ return modelCatalog(id, rows)
164
+ } catch {
165
+ return defaultCatalog
166
+ }
131
167
  }
132
168
 
133
169
  // Advertise adaptive thinking + effort only for models whose capability table
@@ -1,5 +1,6 @@
1
1
  import { LlmError } from "@deepseek-ai/dsh-llm"
2
- import { openaiMessages, openaiTools } from "../messages.js"
2
+ import { openaiMessages, openaiTools, modelCatalog } from "../messages.js"
3
+ import { fetchWithTimeout } from "../http.js"
3
4
  import { openaiChatStream, readJson, httpError } from "../wire.js"
4
5
  import { asUsageSnapshot } from "../usage.js"
5
6
 
@@ -68,8 +69,50 @@ export function authorizeUrl() {
68
69
  return "https://github.com/login/device"
69
70
  }
70
71
 
71
- export async function listModels() {
72
- return COPILOT_MODELS
72
+ export async function listModels(blob, cfg, fetchImpl) {
73
+ const impl = fetchImpl || fetch
74
+ const fallback = modelCatalog(id, (cfg && cfg.models) || COPILOT_MODELS)
75
+ if (!blob) return fallback
76
+ const githubToken = blob.refreshToken || blob.githubToken
77
+ let copilotToken = blob.accessToken
78
+ try {
79
+ if ((!copilotToken || !blob.expiresAt || blob.expiresAt < Date.now() + 60000) && githubToken) {
80
+ const exchange = await exchangeCopilotToken(githubToken, impl)
81
+ if (exchange && exchange.token) {
82
+ copilotToken = exchange.token
83
+ blob.accessToken = exchange.token
84
+ if (exchange.expires_at) blob.expiresAt = exchange.expires_at * 1000
85
+ }
86
+ }
87
+ if (!copilotToken) return fallback
88
+ const res = await fetchWithTimeout(impl, "https://api.githubcopilot.com/models", {
89
+ headers: {
90
+ Authorization: `Bearer ${copilotToken}`,
91
+ Accept: "application/json",
92
+ "User-Agent": "GitHubCopilotChat/0.24.0",
93
+ },
94
+ }, { timeoutMs: 15000 })
95
+ if (!res.ok) return fallback
96
+ const json = await readJson(res)
97
+ const items = json && (json.data || json.models || json)
98
+ if (!Array.isArray(items) || !items.length) return fallback
99
+ const rows = []
100
+ for (const item of items) {
101
+ const modelId = item.id || item.name
102
+ if (!modelId) continue
103
+ if (item.capabilities && item.capabilities.type && item.capabilities.type !== "chat") continue
104
+ rows.push({
105
+ id: modelId,
106
+ name: item.name || modelId,
107
+ ...(item.capabilities && item.capabilities.limits && item.capabilities.limits.max_prompt_tokens ? { contextWindow: item.capabilities.limits.max_prompt_tokens } : {}),
108
+ ...(item.capabilities && item.capabilities.supports && item.capabilities.supports.vision ? { inputModalities: ["text", "image"] } : {}),
109
+ })
110
+ }
111
+ if (!rows.length) return fallback
112
+ return modelCatalog(id, rows)
113
+ } catch {
114
+ return fallback
115
+ }
73
116
  }
74
117
 
75
118
  export function getTelemetryHeaders(sessionId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.37",
3
+ "version": "0.6.39",
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",