@goodandready/dsh-subscriptions 0.6.37 → 0.6.38

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
@@ -13,7 +13,6 @@ export function normalizeSlots(slots) {
13
13
  const out = []
14
14
  const seen = new Set()
15
15
  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
16
  if (!slot || typeof slot !== 'object') continue
18
17
  if (!isProvider(slot.provider)) continue
19
18
  const index = Number(slot.index)
@@ -22,10 +21,13 @@ export function normalizeSlots(slots) {
22
21
  if (seen.has(ref)) continue
23
22
  seen.add(ref)
24
23
  out.push({
24
+ ...slot,
25
25
  provider: slot.provider,
26
26
  index,
27
27
  label: String(slot.label || ''),
28
28
  ref,
29
+ ...(slot.proxyUrl ? { proxyUrl: String(slot.proxyUrl) } : {}),
30
+ ...(slot.expiresAt ? { expiresAt: Number(slot.expiresAt) } : {}),
29
31
  })
30
32
  }
31
33
  return out
@@ -94,16 +96,27 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
94
96
  }
95
97
 
96
98
  async function saveBlob(ref, blob) {
99
+ if (!ref || typeof ref !== "string") throw new Error("invalid ref for saveBlob")
97
100
  const prev = writeQueues.get(ref) || Promise.resolve()
98
101
  const next = prev.catch(() => {}).then(async () => {
99
- await credentials.set(credentialRef(ref), serializeBlob(blob))
102
+ try {
103
+ await credentials.set(credentialRef(ref), serializeBlob(blob))
104
+ } catch (err) {
105
+ const wrapErr = new Error(`failed to persist credentials for ${ref}: ${err && err.message || err}`)
106
+ wrapErr.code = "CREDENTIAL_SAVE_FAILED"
107
+ wrapErr.cause = err
108
+ throw wrapErr
109
+ }
100
110
  })
101
111
  writeQueues.set(ref, next)
102
112
  await next
103
113
  }
104
114
 
105
115
  async function clearRef(ref) {
106
- await credentials.unset(credentialRef(ref))
116
+ if (!ref || typeof ref !== "string") return
117
+ try {
118
+ await credentials.unset(credentialRef(ref))
119
+ } catch { /* best-effort unset */ }
107
120
  cooldowns.delete(ref)
108
121
  quotas.delete(ref)
109
122
  quarantines.delete(ref)
@@ -202,6 +215,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
202
215
  }
203
216
 
204
217
  async function ensureFresh(provider, blob, ref) {
218
+ if (!blob || typeof blob !== 'object') return blob
219
+ if (blob.apiKeyOnly || (blob.apiKey && (!blob.refreshToken || blob.refreshToken === blob.apiKey))) return blob
205
220
  if (!blob.refreshToken) return blob
206
221
  if (blob.expiresAt && blob.expiresAt - SKEW_MS > Date.now()) return blob
207
222
  const lockKey = ref || (provider + ":" + (blob.accountId || blob.email || (blob.refreshToken && blob.refreshToken.slice(-16)) || "anon"))
@@ -202,6 +202,8 @@ export function createAdapterManager({
202
202
  }
203
203
 
204
204
  let lastModelRefresh = 0
205
+ const providerModelCache = new Map()
206
+
205
207
  async function refreshModels() {
206
208
  if (Date.now() - lastModelRefresh < 60 * 1000) return
207
209
  lastModelRefresh = Date.now()
@@ -214,7 +216,10 @@ export function createAdapterManager({
214
216
  if (!blob) continue
215
217
  const fresh = await store.ensureFresh(provider, blob, slot.ref)
216
218
  const cfg = vendorConfig(provider, live())
217
- await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => {})
219
+ const liveModels = await getVendor(provider).listModels(fresh, cfg, fetch).catch(() => null)
220
+ if (Array.isArray(liveModels) && liveModels.length) {
221
+ providerModelCache.set(provider, liveModels)
222
+ }
218
223
  } catch { /* best-effort */ }
219
224
  }
220
225
  }
package/lib/adapter.js CHANGED
@@ -45,20 +45,24 @@ export class SubscriptionAdapter extends LlmAdapter {
45
45
  async listModels(provider) {
46
46
  const accounts = await this.deps.listAccounts(provider)
47
47
  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
48
  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)
49
+ if (targetAccount) {
50
+ const cfg = this.deps.vendorConfig(provider)
51
+ const fetchImpl = pickFetch(this.deps, targetAccount.ref)
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
+ }
62
+ if (!models) {
63
+ const cfg = this.deps.vendorConfig(provider)
64
+ models = modelCatalog(provider, cfg ? cfg.models : undefined)
65
+ }
62
66
  // #94: optionally hide test/preview/beta/legacy ids from the picker.
63
67
  if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
64
68
  models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
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
-
@@ -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
+ }
@@ -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.38",
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",