@goodandready/dsh-subscriptions 0.6.36 → 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
+ }
package/lib/http.js CHANGED
@@ -131,7 +131,49 @@ export function queryOf(req) {
131
131
  /**
132
132
  * Fetch with connect/overall timeout using AbortSignal.any.
133
133
  */
134
- export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs = 30000 } = {}) {
134
+
135
+ export function isSameOrigin(url1, url2) {
136
+ try {
137
+ const u1 = new URL(url1)
138
+ const u2 = new URL(url2)
139
+ return u1.origin.toLowerCase() === u2.origin.toLowerCase()
140
+ } catch {
141
+ return false
142
+ }
143
+ }
144
+
145
+ export function sanitizeRequestHeaders(url, init = {}, options = {}) {
146
+ const headers = { ...(init.headers || {}) }
147
+ if (!url) return headers
148
+ try {
149
+ const parsed = new URL(url)
150
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
151
+ delete headers.authorization
152
+ delete headers.Authorization
153
+ delete headers.cookie
154
+ delete headers.Cookie
155
+ return headers
156
+ }
157
+ const allowedOrigins = options.allowedOrigins || []
158
+ if (allowedOrigins.length > 0) {
159
+ const targetOrigin = parsed.origin.toLowerCase()
160
+ const isAllowed = allowedOrigins.some(o => {
161
+ try { return new URL(o).origin.toLowerCase() === targetOrigin } catch { return false }
162
+ })
163
+ if (!isAllowed) {
164
+ delete headers.authorization
165
+ delete headers.Authorization
166
+ delete headers.cookie
167
+ delete headers.Cookie
168
+ }
169
+ }
170
+ } catch {}
171
+ return headers
172
+ }
173
+
174
+ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs = 30000, allowedOrigins } = {}) {
175
+ const safeHeaders = sanitizeRequestHeaders(url, init, { allowedOrigins })
176
+ init = { ...init, headers: safeHeaders }
135
177
  const impl = fetchImpl || fetch
136
178
  if (!timeoutMs || timeoutMs <= 0) return impl(url, init)
137
179
  const controller = new AbortController()
@@ -232,7 +232,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
232
232
  accessToken: tok,
233
233
  refreshToken: tok,
234
234
  githubToken: tok,
235
- expiresAt: Date.now() + 30 * 86400 * 1000,
235
+ expiresAt: 0,
236
236
  email,
237
237
  label: email,
238
238
  }
package/lib/index.js CHANGED
@@ -198,12 +198,16 @@ export function apply(ctx, rawConfig) {
198
198
  return proxyFetch(slot.proxyUrl)
199
199
  }
200
200
 
201
- function redirectFor(ref, provider) {
201
+ function redirectFor(provider, ref, reqOrigin) {
202
+ if (typeof provider === "object" && provider !== null) {
203
+ const opts = provider
204
+ provider = opts.provider
205
+ ref = opts.ref
206
+ reqOrigin = opts.origin
207
+ }
202
208
  if (live().useWebCallback) {
203
- const origin = (ctx.webServer && typeof ctx.webServer.url === 'string')
204
- ? ctx.webServer.url
205
- : ''
206
- return webCallbackUri(origin, ref)
209
+ const origin = reqOrigin || ((ctx.webServer && typeof ctx.webServer.url === "string") ? ctx.webServer.url : "")
210
+ return webCallbackUri(origin)
207
211
  }
208
212
  return getVendor(provider).redirectUri(vendorConfig(provider, live()))
209
213
  }
@@ -359,20 +363,17 @@ export function apply(ctx, rawConfig) {
359
363
  }
360
364
 
361
365
  async function completeOAuth({ provider, index, code, state }) {
362
- if (!isProvider(provider)) throw new Error('unknown provider')
363
- const n = Number(index)
364
- const ref = oauthRef(provider, n)
366
+ if (!state) throw new Error('missing oauth state')
365
367
  sweepPending(Date.now())
366
- let row = state ? pending.get(state) : null
367
- if (!row) {
368
- for (const item of pending.values()) {
369
- if (item.provider === provider && item.index === n) row = item
370
- }
371
- }
372
- if (!row || !row.verifier) throw new Error('login session expired; start Connect again')
373
- if (row.provider !== provider || row.index !== n) throw new Error('state does not match this account')
374
- const cfg = { ...vendorConfig(provider, live()), redirectUri: row.redirectUri }
375
- const blob = await getVendor(provider).exchangeCode(cfg, {
368
+ const row = pending.get(state)
369
+ if (!row || !row.verifier) throw new Error('login session expired or invalid state; start Connect again')
370
+ if (provider && row.provider !== provider) throw new Error('state provider mismatch')
371
+ if (index !== undefined && index !== null && Number(index) !== row.index) throw new Error('state index mismatch')
372
+ const actualProvider = row.provider
373
+ const n = row.index
374
+ const ref = oauthRef(actualProvider, n)
375
+ const cfg = { ...vendorConfig(actualProvider, live()), redirectUri: row.redirectUri }
376
+ const blob = await getVendor(actualProvider).exchangeCode(cfg, {
376
377
  verifier: row.verifier,
377
378
  challenge: row.challenge,
378
379
  state: row.state,
@@ -384,7 +385,7 @@ export function apply(ctx, rawConfig) {
384
385
  pending.delete(row.state)
385
386
  await syncAdapter()
386
387
  refreshModels().catch(() => {})
387
- return { ref, label: pmL(blob.label || blob.email) || displayName(provider) }
388
+ return { ref, label: pmL(blob.label || blob.email) || displayName(actualProvider) }
388
389
  }
389
390
 
390
391
  // Privacy masking
@@ -4,7 +4,7 @@ import { Config, plainConfig, publicConfig } from '../config-schema.js'
4
4
  import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
5
5
  import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
6
6
  import { discoverLocalCliSessions, loadLocalCliBlob } from '../import-auth.js'
7
- import { isProvider, oauthRef } from '../refs.js'
7
+ import { isProvider, oauthRef, parseOauthRef } from '../refs.js'
8
8
 
9
9
  export function registerAccountsRoutes(ctx, state) {
10
10
  const {
@@ -143,8 +143,13 @@ export function registerAccountsRoutes(ctx, state) {
143
143
  } catch {
144
144
  body = null
145
145
  }
146
- if (!body || !body.provider) {
147
- writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'missing provider' } })
146
+ if (!body || !body.provider || !isProvider(body.provider)) {
147
+ writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'unknown or missing provider' } })
148
+ return
149
+ }
150
+ const idxNum = Number(body.index) || 1
151
+ if (!Number.isInteger(idxNum) || idxNum < 1 || idxNum > 99) {
152
+ writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'invalid account index' } })
148
153
  return
149
154
  }
150
155
  try {
@@ -164,7 +169,7 @@ export function registerAccountsRoutes(ctx, state) {
164
169
  syncCustomVendors()
165
170
  }
166
171
  const slot = normalizeSlots(live().slots).find((s) => s.provider === prov && s.index === idx)
167
- const ref = slot ? slot.ref : `${prov.toUpperCase()}_OAUTH_${idx}`
172
+ const ref = slot ? slot.ref : oauthRef(prov, idx)
168
173
  await store.saveBlob(ref, blob)
169
174
  await syncAdapter()
170
175
  refreshModels().catch(() => {})
@@ -201,11 +206,12 @@ export function registerAccountsRoutes(ctx, state) {
201
206
  writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
202
207
  return
203
208
  }
204
- const passphrase = payload.passphrase
205
- if (!passphrase || typeof passphrase !== 'string') {
206
- writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'passphrase is required' } })
209
+ const passphrase = String(payload.passphrase || '').trim()
210
+ if (!passphrase || passphrase.length < 12) {
211
+ writeJson(res, 400, { ok: false, error: { code: 'passphrase', message: 'passphrase must be at least 12 characters' } })
207
212
  return
208
213
  }
214
+ const algorithm = payload.algorithm === 'pbkdf2' || payload.algorithm === 'scrypt-v2' || payload.algorithm === 'scrypt-hardened' ? payload.algorithm : 'scrypt'
209
215
  try {
210
216
  const accounts = []
211
217
  for (const slot of normalizeSlots(live().slots)) {
@@ -219,7 +225,7 @@ export function registerAccountsRoutes(ctx, state) {
219
225
  return
220
226
  }
221
227
  const bundle = JSON.stringify({ v: 1, exportedAt: Date.now(), accounts })
222
- const encrypted = encryptWithPassphrase(bundle, passphrase)
228
+ const encrypted = encryptWithPassphrase(bundle, passphrase, { algorithm })
223
229
  writeJson(res, 200, { ok: true, payload: encrypted, count: accounts.length })
224
230
  } catch (e) {
225
231
  writeJson(res, 500, { ok: false, error: { code: 'export', message: String(e && e.message || e) } })
@@ -265,6 +271,7 @@ export function registerAccountsRoutes(ctx, state) {
265
271
  let imported = 0
266
272
  for (const row of bundle.accounts) {
267
273
  try {
274
+ if (!row || typeof row.ref !== 'string' || !parseOauthRef(row.ref) || !row.blob) continue
268
275
  await store.saveBlob(row.ref, row.blob)
269
276
  imported++
270
277
  } catch { /* skip broken */ }
@@ -301,6 +308,10 @@ export function registerAccountsRoutes(ctx, state) {
301
308
  writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
302
309
  return
303
310
  }
311
+ if (!Number.isInteger(index) || index < 1 || index > 99) {
312
+ writeJson(res, 400, { ok: false, error: { code: 'index', message: 'account index must be an integer between 1 and 99' } })
313
+ return
314
+ }
304
315
  if (!refreshToken && !apiKey) {
305
316
  writeJson(res, 400, { ok: false, error: { code: 'token', message: 'refreshToken or apiKey is required' } })
306
317
  return
@@ -39,7 +39,7 @@ export function registerOauthRoutes(ctx, state) {
39
39
  return
40
40
  }
41
41
  const origin = requestOrigin(req)
42
- const redirectUri = redirectFor(provider, live(), origin)
42
+ const redirectUri = redirectFor(provider, `${provider}:${index}`, origin)
43
43
  const pkce = await createPkce()
44
44
  pending.set(pkce.state, {
45
45
  provider,
@@ -1,6 +1,7 @@
1
+ // #374: Sensitive GET routes protected by same-origin check
1
2
  import { getRecentAlerts, clearAlerts } from '../alerts.js'
2
3
  import { normalizeSlots, vendorConfig } from '../accounts.js'
3
- import { Config, plainConfig, publicConfig } from '../config-schema.js'
4
+ import { Config, plainConfig, publicConfig, restoreMaskedSecrets } from '../config-schema.js'
4
5
  import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
5
6
  import { quotaSnapshot } from '../ratelimit.js'
6
7
  import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
@@ -64,7 +65,8 @@ export function registerStatusRoutes(ctx, state) {
64
65
  return
65
66
  }
66
67
  // #379: revision conflict detection (CAS)
67
- 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
68
70
  if (baseRev != null && !Number.isNaN(baseRev) && baseRev !== currentRevision) {
69
71
  writeJson(res, 409, {
70
72
  ok: false,
@@ -79,13 +81,16 @@ export function registerStatusRoutes(ctx, state) {
79
81
 
80
82
  if (payload && typeof payload.config === 'object') payload = payload.config
81
83
  try {
84
+ payload = restoreMaskedSecrets(payload, live())
82
85
  if (Array.isArray(payload.slots)) payload.slots = stripLegacySlots(payload.slots)
83
86
  const parsed = plainConfig(Config(payload))
84
87
  const dropped = droppedCredentialRefs(live().slots, parsed.slots)
85
88
  await getSettingsApi().replace(parsed)
86
89
  currentRevision++
87
90
  syncCustomVendors()
88
- 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
+ }
89
94
  await syncAdapter()
90
95
  writeJson(res, 200, await configResponse())
91
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.36",
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",