@goodandready/dsh-subscriptions 0.6.84 → 0.6.86

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/client.js CHANGED
@@ -873,6 +873,7 @@ window.__ModuleLoader__.load({
873
873
  const [modelsCatalog, setModelsCatalog] = React.useState([])
874
874
  const [paste, setPaste] = React.useState({})
875
875
  const [device, setDevice] = React.useState({}) // #90 key -> {state,userCode,authUrl,intervalMs,status}
876
+ const [loginLinks, setLoginLinks] = React.useState({}) // #595 provider:index -> fallback auth URL when a popup is blocked
876
877
  const [proxyRes, setProxyRes] = React.useState({}) // #88 key -> {ok,latencyMs,error}
877
878
  const [diag, setDiag] = React.useState('')
878
879
  const [sessionAnalysis, setSessionAnalysis] = React.useState(null)
@@ -1324,13 +1325,31 @@ window.__ModuleLoader__.load({
1324
1325
  setErr(t('missingClientIdAntigravity'))
1325
1326
  return
1326
1327
  }
1327
- const res = await fetch('/dsh-subscriptions/oauth/start?provider=' + encodeURIComponent(provider) + '&index=' + encodeURIComponent(index), { cache: 'no-store' })
1328
- const data = await res.json().catch(() => ({}))
1329
- if (!res.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
1330
- const key = provider + ':' + index
1331
- if (data.headless) setHeadlessInfo((m) => Object.assign({}, m, { [key]: data.headless }))
1332
- const authTarget = safeExternalUrl(data.url)
1333
- if (authTarget) window.open(authTarget, '_blank', 'noopener')
1328
+ // #595: open the popup synchronously, before the first await, so the
1329
+ // click gesture is still valid in Chrome and Firefox.
1330
+ let popup = null
1331
+ try { popup = window.open('', '_blank') } catch { popup = null }
1332
+ try {
1333
+ const res = await fetch('/dsh-subscriptions/oauth/start?provider=' + encodeURIComponent(provider) + '&index=' + encodeURIComponent(index), { cache: 'no-store' })
1334
+ const data = await res.json().catch(() => ({}))
1335
+ if (!res.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
1336
+ const key = provider + ':' + index
1337
+ if (data.headless) setHeadlessInfo((m) => Object.assign({}, m, { [key]: data.headless }))
1338
+ const authTarget = safeExternalUrl(data.url)
1339
+ if (authTarget) {
1340
+ if (popup && !popup.closed) {
1341
+ popup.location.href = authTarget
1342
+ try { popup.opener = null } catch { /* cross-origin */ }
1343
+ } else {
1344
+ setLoginLinks((m) => Object.assign({}, m, { [key]: authTarget }))
1345
+ }
1346
+ } else if (popup && !popup.closed) {
1347
+ popup.close()
1348
+ }
1349
+ } catch (e) {
1350
+ if (popup && !popup.closed) popup.close()
1351
+ throw e
1352
+ }
1334
1353
  }
1335
1354
 
1336
1355
  // #90: device-code login (headless). Server mints user_code; we show it,
@@ -2753,6 +2772,9 @@ window.__ModuleLoader__.load({
2753
2772
  }, '🔄 ' + t('reconnectNow'))
2754
2773
  ) : null,
2755
2774
  (function(){ var r=checkRes[key]; if(!r) return null; var txt=r.ok ? ('ok ' + (r.email||'')) : ('fail ' + (r.error && r.error.message || '')); var cls=r.ok ? 'dsub-ok' : 'dsub-bad'; var q=r.quota; if(q && q.remaining!=null) txt += ' quota:'+q.remaining+(q.limit!=null?'/'+q.limit:''); return h('div', {className: cls}, txt) })(),
2775
+ loginLinks[key] ? h('div', { className: 'dsub-verify' },
2776
+ h('a', { href: loginLinks[key], target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink'))
2777
+ ) : null,
2756
2778
  safeExternalUrl(account.validationUrl) ? h('div', { className: 'dsub-verify' },
2757
2779
  t('verifyPrefix'),
2758
2780
  h('a', { href: safeExternalUrl(account.validationUrl) || undefined, target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink')),
@@ -3659,7 +3681,12 @@ window.__ModuleLoader__.load({
3659
3681
  fetch('/dsh-subscriptions/oauth/start?provider=' + encodeURIComponent(prov) + '&index=1', { cache: 'no-store' })
3660
3682
  .then((r) => r.json()).then((data) => {
3661
3683
  const target = data && safeExternalUrl(data.url)
3662
- if (target) window.open(target, '_blank', 'noopener')
3684
+ if (target) {
3685
+ const popup = window.open(target, '_blank', 'noopener')
3686
+ if (!popup) {
3687
+ try { navigator.clipboard.writeText(target) } catch { /* clipboard blocked */ }
3688
+ }
3689
+ }
3663
3690
  }).catch(() => {})
3664
3691
  }
3665
3692
  const line = (line) => line.trim()
package/lib/http.js CHANGED
@@ -72,9 +72,9 @@ export function isLoopback(value) {
72
72
  return false
73
73
  }
74
74
  const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '')
75
- return address === 'localhost' || address === 'localhost.' || address === '::1'
76
- || address.startsWith('127.')
77
- || address.startsWith('::ffff:127.')
75
+ if (address === 'localhost' || address === 'localhost.' || address === '::1') return true
76
+ if (address.startsWith('::ffff:')) return isLoopbackIPv4(address.slice('::ffff:'.length))
77
+ return isLoopbackIPv4(address)
78
78
  }
79
79
 
80
80
 
@@ -603,7 +603,13 @@ export function registerAccountsRoutes(ctx, state) {
603
603
  return
604
604
  }
605
605
  try {
606
- const ref = oauthRef(payload.provider, payload.index)
606
+ const provider = String(payload.provider || '')
607
+ const index = Number(payload.index)
608
+ if (!isProvider(provider) || !Number.isInteger(index) || index < 1 || index > 99) {
609
+ writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'invalid provider or index' } })
610
+ return
611
+ }
612
+ const ref = oauthRef(provider, index)
607
613
  await store.clearRef(ref)
608
614
  await syncAdapter()
609
615
  refreshModels().catch(() => {})
@@ -431,11 +431,6 @@ export function registerStatusRoutes(ctx, state) {
431
431
  writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
432
432
  return
433
433
  }
434
- const host = (req.headers.host || '').split(':')[0]
435
- if (host !== 'localhost' && host !== '127.0.0.1') {
436
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'localhost only' } })
437
- return
438
- }
439
434
  const limit = Math.max(1, Math.min(Number(queryOf(req).get('limit') || '10'), 100))
440
435
  writeJson(res, 200, { ok: true, total: history.size(), items: history.recent(limit) })
441
436
  }),
package/lib/sse.js CHANGED
@@ -1,17 +1,24 @@
1
1
  /**
2
2
  * @param {any} body
3
- * @param {{ idleTimeoutMs?: number, signal?: AbortSignal }} [options]
3
+ * @param {{ idleTimeoutMs?: number, totalTimeoutMs?: number, maxBufferBytes?: number, signal?: AbortSignal }} [options]
4
4
  * @returns {AsyncGenerator<string, void, unknown>}
5
5
  */
6
- export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {}) {
6
+ export async function* iterateSse(body, { idleTimeoutMs = 60000, totalTimeoutMs = 600000, maxBufferBytes = 8 * 1024 * 1024, signal } = {}) {
7
7
  const reader = body && typeof body.getReader === 'function' ? body.getReader() : null
8
8
  const decoder = new TextDecoder()
9
9
  let buffer = ''
10
10
  let terminated = false
11
+ const deadline = totalTimeoutMs > 0 ? Date.now() + totalTimeoutMs : 0
11
12
 
12
13
  async function* fromText(chunk) {
13
14
  if (terminated) return
15
+ if (chunk.length > maxBufferBytes) {
16
+ throw new Error('SSE buffer limit exceeded (' + maxBufferBytes + ' chars)')
17
+ }
14
18
  buffer += chunk
19
+ if (buffer.length > maxBufferBytes) {
20
+ throw new Error('SSE buffer limit exceeded (' + maxBufferBytes + ' chars)')
21
+ }
15
22
  let sep
16
23
  while ((sep = buffer.search(/\r?\n\r?\n/)) >= 0) {
17
24
  const raw = buffer.slice(0, sep)
@@ -32,7 +39,7 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
32
39
  }
33
40
  }
34
41
 
35
- async function readWithTimeout(r) {
42
+ async function readWithTimeout(r, timeoutMs) {
36
43
  if (signal && signal.aborted) {
37
44
  if (typeof r.cancel === 'function') await r.cancel().catch(() => {})
38
45
  const err = new Error('stream aborted by client')
@@ -41,12 +48,15 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
41
48
  }
42
49
  let timer
43
50
  const timeoutPromise = new Promise((_, reject) => {
44
- if (!idleTimeoutMs || idleTimeoutMs <= 0) return
51
+ if (!timeoutMs || timeoutMs <= 0) return
45
52
  timer = setTimeout(() => {
46
- const err = /** @type {Error & { code?: string }} */ (new Error(`stream idle timeout: no data received for ${Math.round(idleTimeoutMs / 1000)}s`))
53
+ const message = timeoutMs === idleTimeoutMs
54
+ ? `stream idle timeout: no data received for ${Math.round(timeoutMs / 1000)}s`
55
+ : 'SSE stream deadline exceeded'
56
+ const err = /** @type {Error & { code?: string }} */ (new Error(message))
47
57
  err.code = 'TIMEOUT'
48
58
  reject(err)
49
- }, idleTimeoutMs)
59
+ }, timeoutMs)
50
60
  })
51
61
 
52
62
  let abortPromise
@@ -77,7 +87,13 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
77
87
  if (reader) {
78
88
  try {
79
89
  while (!terminated) {
80
- const { done, value } = await readWithTimeout(reader)
90
+ let wait = idleTimeoutMs
91
+ if (deadline) {
92
+ const remaining = deadline - Date.now()
93
+ if (remaining <= 0) throw new Error('SSE stream deadline exceeded')
94
+ wait = idleTimeoutMs > 0 ? Math.min(idleTimeoutMs, remaining) : remaining
95
+ }
96
+ const { done, value } = await readWithTimeout(reader, wait)
81
97
  if (done) break
82
98
  yield* fromText(decoder.decode(value, { stream: true }))
83
99
  if (terminated) break
@@ -92,6 +108,7 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
92
108
  }
93
109
  if (body && typeof body[Symbol.asyncIterator] === 'function') {
94
110
  for await (const chunk of body) {
111
+ if (deadline && Date.now() >= deadline) throw new Error('SSE stream deadline exceeded')
95
112
  if (signal && signal.aborted) {
96
113
  const err = new Error('stream aborted by client')
97
114
  err.name = 'AbortError'
@@ -108,6 +125,7 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
108
125
  return
109
126
  }
110
127
  const text = typeof body === 'string' ? body : await new Response(body).text()
128
+ if (text.length > maxBufferBytes) throw new Error('SSE buffer limit exceeded (' + maxBufferBytes + ' chars)')
111
129
  yield* fromText(text)
112
130
  }
113
131
 
@@ -1,3 +1,4 @@
1
+ import { readErrorBody } from './wire.js'
1
2
  import { isProvider } from "./refs.js"
2
3
  import { pickAccountWithTrace, markCooldown, isSwitchableError, modelFamily } from "./rotate.js"
3
4
  import { quotaSnapshot } from "./ratelimit.js"
@@ -262,7 +263,7 @@ export function createSubscriptionsService(deps) {
262
263
  } catch { /* ignore subscriptions error */ }
263
264
  }
264
265
  if (!res.ok) {
265
- const txt = await res.text().catch(() => "")
266
+ const txt = await readErrorBody(res)
266
267
  const err = new Error("vendor http " + res.status + (txt ? ": " + txt.slice(0, 200) : ""))
267
268
  err.status = res.status
268
269
  if (res.status === 429) err.code = "RATE_LIMIT"
package/lib/updater.js CHANGED
@@ -16,11 +16,19 @@ function header(request, name) {
16
16
  return Array.isArray(value) ? value[0] : value
17
17
  }
18
18
 
19
+ function isLoopbackIPv4(address) {
20
+ if (!address || !address.startsWith('127.')) return false
21
+ const parts = address.split('.')
22
+ if (parts.length !== 4) return false
23
+ return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
24
+ }
25
+
19
26
  export function isLoopback(value) {
20
27
  const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
21
- return address === 'localhost' || address === 'localhost.' || address === '::1'
22
- || address?.startsWith('127.') === true
23
- || address?.startsWith('::ffff:127.') === true
28
+ if (!address) return false
29
+ if (address === 'localhost' || address === 'localhost.' || address === '::1') return true
30
+ const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
31
+ return isLoopbackIPv4(ipv4)
24
32
  }
25
33
 
26
34
  export function isTrustedUpdateRequest(request, targetCtx) {
@@ -10,7 +10,7 @@ import { fetchWithTimeout } from "./http.js"
10
10
 
11
11
  import { buildAuthorizeUrl } from "./oauth.js"
12
12
  import { codexResponsesBody, modelCatalog } from "./messages.js"
13
- import { formTokenRequest, jsonTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError } from "./wire.js"
13
+ import { formTokenRequest, jsonTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError , readErrorBody } from "./wire.js"
14
14
  import { emailFromToken } from "./jwt.js"
15
15
 
16
16
  function defaultModels(providerId, models) {
@@ -92,6 +92,7 @@ export function createVendorFromProfile(profile) {
92
92
  const res = await fetchWithTimeout(fetchImpl || fetch, `${base}${profile.modelsPath}`, {
93
93
  headers: identityHeaders(blob, { Accept: "application/json" }),
94
94
  }, { timeoutMs: 15000 })
95
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
95
96
  const json = await readJson(res)
96
97
  const rows = []
97
98
  for (const entry of json.data || json.models || []) {
@@ -101,7 +102,9 @@ export function createVendorFromProfile(profile) {
101
102
  }
102
103
  if (!rows.length) throw new Error("empty catalog")
103
104
  return modelCatalog(id, rows)
104
- } catch {
105
+ } catch (err) {
106
+ const status = Number(err && (err.status || err.statusCode) || 0)
107
+ if (status === 401 || status === 403 || (err && err.code === 'AUTH')) throw err
105
108
  return catalog
106
109
  }
107
110
  },
@@ -125,7 +128,7 @@ export function createVendorFromProfile(profile) {
125
128
  body: JSON.stringify(body),
126
129
  signal,
127
130
  }, { timeoutMs: 60000, stream: true })
128
- if (!res.ok) throw httpError(res.status, await res.text())
131
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
129
132
  yield* codexResponsesStream(res.body)
130
133
  },
131
134
  }
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto'
2
2
  import { buildAuthorizeUrl } from '../oauth.js'
3
3
  import { googleContents } from '../messages.js'
4
- import { formTokenRequest, googleStream, throwHttpError, tokenBlobFromOAuth } from '../wire.js'
4
+ import { formTokenRequest, googleStream, throwHttpError, tokenBlobFromOAuth , readErrorBody } from '../wire.js'
5
5
  import { fetchWithTimeout } from '../http.js'
6
6
  import { emailFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
7
7
  import {
@@ -239,7 +239,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, signal, s
239
239
  body: JSON.stringify(envelope),
240
240
  signal,
241
241
  }, { timeoutMs: RESPONSE_TIMEOUT_MS })
242
- if (!res.ok) throwHttpError(res.status, await res.text())
242
+ if (!res.ok) throwHttpError(res.status, await readErrorBody(res))
243
243
  if (saveBlob && sessionId && sessionId !== ready.sessionId) {
244
244
  await saveBlob({ ...ready, sessionId })
245
245
  }
@@ -1,7 +1,8 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { fetchWithTimeout } from '../http.js'
2
3
  import { buildAuthorizeUrl } from '../oauth.js'
3
4
  import { anthropicPayload, modelCatalog } from '../messages.js'
4
- import { jsonTokenRequest, anthropicStream, httpError, tokenBlobFromOAuth, readJson } from '../wire.js'
5
+ import { jsonTokenRequest, anthropicStream, httpError, tokenBlobFromOAuth, readJson , readErrorBody } from '../wire.js'
5
6
  import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
6
7
  import { asUsageSnapshot, usageWindows, deepestUsedPercent } from '../usage.js'
7
8
 
@@ -253,7 +254,7 @@ function applyClaudeThinking(payload, options) {
253
254
  export async function check(blob, _cfg, fetchImpl) {
254
255
  const impl = fetchImpl || fetch
255
256
  const res = await impl(PROFILE, { headers: oauthHeaders(blob.accessToken) })
256
- if (!res.ok) throw httpError(res.status, await res.text())
257
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
257
258
  const json = await readJson(res)
258
259
  return { ok: true, raw: json }
259
260
  }
@@ -273,7 +274,7 @@ export async function usage(blob, _cfg, fetchImpl) {
273
274
  const billingAccountState = new Map()
274
275
  const MAX_BILLING_ACCOUNTS = 256
275
276
 
276
- function resolveAccountKey(cfg, context) {
277
+ export function resolveAccountKey(cfg, context) {
277
278
  const explicit = (context && context.accountId) || (cfg && cfg.accountId)
278
279
  if (typeof explicit === 'string' && explicit.trim()) return explicit.trim()
279
280
  const blob = (context && context.blob) || (cfg && cfg.blob)
@@ -281,7 +282,7 @@ function resolveAccountKey(cfg, context) {
281
282
  const id = blob.accountId || blob.id || blob.email
282
283
  if (typeof id === 'string' && id.trim()) return id.trim()
283
284
  if (typeof blob.accessToken === 'string' && blob.accessToken.length >= 8) {
284
- return `token:${blob.accessToken.slice(-12)}`
285
+ return 'token:' + createHash('sha256').update(String(blob.accessToken)).digest('hex').slice(0, 16)
285
286
  }
286
287
  }
287
288
  return 'default'
@@ -357,6 +358,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
357
358
  body: JSON.stringify(payload),
358
359
  signal,
359
360
  })
360
- if (!res.ok) throw httpError(res.status, await res.text())
361
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
361
362
  yield* anthropicStream(res.body)
362
363
  }
@@ -3,7 +3,7 @@ import { randomUUID, createHash } from 'node:crypto'
3
3
  import { buildAuthorizeUrl } from '../oauth.js'
4
4
  import { codexResponsesBody, modelCatalog } from '../messages.js'
5
5
  import { chatgptAccountId, emailFromToken, planFromToken, avatarFromToken, scopesFromToken, organizationsFromToken } from '../jwt.js'
6
- import { formTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError } from '../wire.js'
6
+ import { formTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError , readErrorBody } from '../wire.js'
7
7
  import { asUsageSnapshot, usageWindows, deepestUsedPercent } from '../usage.js'
8
8
 
9
9
  export const id = 'codex'
@@ -129,7 +129,7 @@ export async function deviceStart(cfg, fetchImpl) {
129
129
  headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
130
130
  body: JSON.stringify({ client_id: cfg.clientId }),
131
131
  })
132
- if (!res.ok) throw httpError(res.status, await res.text().catch(() => ''), 'DEVICE')
132
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res), 'DEVICE')
133
133
  const json = await readJson(res)
134
134
  if (!json || typeof json.user_code !== 'string' || typeof json.device_auth_id !== 'string') {
135
135
  throw httpError(502, 'device auth response is incomplete', 'DEVICE')
@@ -151,7 +151,7 @@ export async function devicePoll(cfg, session, fetchImpl) {
151
151
  })
152
152
  // 403/404 = not confirmed yet (matches reference implementations)
153
153
  if (res.status === 403 || res.status === 404) return { status: 'pending' }
154
- if (!res.ok) throw httpError(res.status, await res.text().catch(() => ''), 'DEVICE')
154
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res), 'DEVICE')
155
155
  const json = await readJson(res)
156
156
  if (!json || typeof json.authorization_code !== 'string' || typeof json.code_verifier !== 'string') {
157
157
  throw httpError(502, 'device auth token response is incomplete', 'DEVICE')
@@ -218,7 +218,7 @@ export async function check(blob, cfg, fetchImpl) {
218
218
  const res = await fetchWithTimeout(impl, base + "/models?client_version=" + encodeURIComponent(version), {
219
219
  headers: identityHeaders(blob, cfg, { Accept: "application/json" }),
220
220
  }, { timeoutMs: 15000 })
221
- if (!res.ok) throw httpError(res.status, await res.text())
221
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
222
222
  const json = await readJson(res)
223
223
  // quota headers may contain limits
224
224
  return { ok: true, raw: json }
@@ -272,7 +272,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
272
272
  body: JSON.stringify(body),
273
273
  signal,
274
274
  })
275
- if (!res.ok) throw httpError(res.status, await res.text())
275
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
276
276
  yield* codexResponsesStream(res.body)
277
277
  }
278
278
  export function deriveSessionId(options) {
@@ -1,6 +1,6 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages } from '../messages.js'
3
- import { openaiChatStream, httpError } from '../wire.js'
3
+ import { openaiChatStream, httpError , readErrorBody } from '../wire.js'
4
4
 
5
5
  export const id = 'cody'
6
6
  const CODY_API_BASE = 'https://sourcegraph.com/.api'
@@ -75,6 +75,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
75
75
  signal
76
76
  })
77
77
 
78
- if (!res.ok) throw httpError(res.status, await res.text())
78
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
79
79
  yield* openaiChatStream(res.body)
80
80
  }
@@ -297,9 +297,17 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
297
297
  throw new LlmError(`Failed to spawn Copilot CLI: ${err && err.message ? err.message : err}`, 'INTERNAL_ERROR')
298
298
  }
299
299
 
300
+ let abortHandler = null
301
+ let killTimer = null
300
302
  if (signal) {
301
- const abortHandler = () => {
303
+ abortHandler = () => {
302
304
  try { child.kill('SIGTERM') } catch { /* best-effort */ }
305
+ killTimer = setTimeout(() => {
306
+ if (!processEnded && !child.killed) {
307
+ try { child.kill('SIGKILL') } catch { /* best-effort */ }
308
+ }
309
+ }, 2000)
310
+ if (killTimer && typeof killTimer.unref === 'function') killTimer.unref()
303
311
  }
304
312
  signal.addEventListener('abort', abortHandler, { once: true })
305
313
  }
@@ -373,6 +381,7 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
373
381
  }
374
382
  })
375
383
 
384
+ try {
376
385
  while (true) {
377
386
  if (chunkQueue.length) {
378
387
  const line = chunkQueue.shift()
@@ -437,6 +446,10 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
437
446
  if (processError && !chunkQueue.length) throw processError
438
447
  }
439
448
  }
449
+ } finally {
450
+ if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
451
+ if (killTimer) clearTimeout(killTimer)
452
+ }
440
453
 
441
454
  yield {
442
455
  type: 'finish',
@@ -1,7 +1,7 @@
1
1
  import { LlmError } from "@deepseek-ai/dsh-llm"
2
2
  import { openaiMessages, openaiTools, modelCatalog } from "../messages.js"
3
3
  import { fetchWithTimeout } from "../http.js"
4
- import { openaiChatStream, readJson, httpError } from "../wire.js"
4
+ import { openaiChatStream, readJson, httpError , readErrorBody } from "../wire.js"
5
5
  import { findCopilotCli, streamCopilotCli, isCopilotCliAvailable, COPILOT_CLI_BINARIES } from "./copilot-cli.js"
6
6
 
7
7
  export const id = "copilot"
@@ -148,7 +148,7 @@ export async function requestDeviceCode(fetchImpl) {
148
148
  scope: "read:user"
149
149
  })
150
150
  })
151
- if (!res.ok) throw httpError(res.status, await res.text())
151
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
152
152
  return readJson(res)
153
153
  }
154
154
 
@@ -166,7 +166,7 @@ export async function pollDeviceToken(deviceCode, fetchImpl) {
166
166
  grant_type: "urn:ietf:params:oauth:grant-type:device_code"
167
167
  })
168
168
  })
169
- if (!res.ok) throw httpError(res.status, await res.text())
169
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
170
170
  return readJson(res)
171
171
  }
172
172
 
@@ -259,7 +259,7 @@ async function exchangeCopilotToken(githubToken, fetchImpl) {
259
259
  "User-Agent": "GitHubCopilotChat/0.24.0"
260
260
  }
261
261
  })
262
- if (!res.ok) throw httpError(res.status, await res.text())
262
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
263
263
  return readJson(res)
264
264
  }
265
265
 
@@ -354,6 +354,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
354
354
  signal
355
355
  })
356
356
 
357
- if (!res.ok) throw httpError(res.status, await res.text())
357
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
358
358
  yield* openaiChatStream(res.body)
359
359
  }
@@ -1,6 +1,6 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages, openaiTools, modelCatalog } from '../messages.js'
3
- import { openaiChatStream, readJson, httpError } from '../wire.js'
3
+ import { openaiChatStream, readJson, httpError , readErrorBody } from '../wire.js'
4
4
  import { asUsageSnapshot } from '../usage.js'
5
5
 
6
6
  export const id = 'cursor'
@@ -201,6 +201,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
201
201
  signal,
202
202
  })
203
203
 
204
- if (!res.ok) throw httpError(res.status, await res.text())
204
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
205
205
  yield* openaiChatStream(res.body)
206
206
  }
@@ -1,11 +1,17 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages } from '../messages.js'
3
- import { openaiChatStream, readJson, httpError } from '../wire.js'
3
+ import { openaiChatStream, readJson, httpError , readErrorBody } from '../wire.js'
4
4
 
5
5
  export const id = 'ernie'
6
6
  export const ERNIE_OAUTH_URL = 'https://aip.baidubce.com/oauth/2.0/token'
7
7
  const ERNIE_BASE_URL = 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat'
8
8
 
9
+ export function ernieChatUrl(token, endpoint, baseUrl = ERNIE_BASE_URL) {
10
+ // #636: the v1 Qianfan endpoint requires access_token in the query. Always
11
+ // encode it so & and # cannot truncate or inject query parameters.
12
+ return baseUrl + '/' + endpoint + '?access_token=' + encodeURIComponent(token)
13
+ }
14
+
9
15
  const ERNIE_MODELS = [
10
16
  {
11
17
  id: 'ernie-speed-128k',
@@ -53,7 +59,7 @@ async function refreshErnieToken(apiKey, secretKey, fetchImpl) {
53
59
  client_secret: secretKey,
54
60
  }),
55
61
  })
56
- if (!res.ok) throw httpError(res.status, await res.text())
62
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
57
63
  return readJson(res)
58
64
  }
59
65
 
@@ -74,7 +80,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
74
80
  if (!token) throw new LlmError('Baidu ERNIE requires access token or API Key + Secret Key', 'AUTH')
75
81
 
76
82
  const endpoint = options.model === 'ernie-4.0-turbo-8k' ? 'completions_pro' : 'ernie_speed'
77
- const url = `${(config && config.apiBase) || ERNIE_BASE_URL}/${endpoint}?access_token=${token}`
83
+ const url = ernieChatUrl(token, endpoint, (config && config.apiBase) || ERNIE_BASE_URL)
78
84
 
79
85
  const messages = openaiMessages(options).map((m) => ({
80
86
  role: m.role === 'system' ? 'user' : m.role,
@@ -91,6 +97,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
91
97
  signal
92
98
  })
93
99
 
94
- if (!res.ok) throw httpError(res.status, await res.text())
100
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
95
101
  yield* openaiChatStream(res.body)
96
102
  }
@@ -1,6 +1,6 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages, openaiTools, modelCatalog } from '../messages.js'
3
- import { openaiChatStream, readJson, httpError } from '../wire.js'
3
+ import { openaiChatStream, readJson, httpError , readErrorBody } from '../wire.js'
4
4
  import { asUsageSnapshot } from '../usage.js'
5
5
 
6
6
  export const id = 'glm'
@@ -109,6 +109,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
109
109
  signal,
110
110
  })
111
111
 
112
- if (!res.ok) throw httpError(res.status, await res.text())
112
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
113
113
  yield* openaiChatStream(res.body)
114
114
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto'
2
2
  import { buildAuthorizeUrl } from '../oauth.js'
3
3
  import { modelCatalog, codexResponsesBody } from '../messages.js'
4
- import { formTokenRequest, codexResponsesStream, httpError, tokenBlobFromOAuth, readJson } from '../wire.js'
4
+ import { formTokenRequest, codexResponsesStream, httpError, tokenBlobFromOAuth, readJson , readErrorBody } from '../wire.js'
5
5
  import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
6
6
  import { asUsageSnapshot, usageWindows, grokBillingPercent } from '../usage.js'
7
7
 
@@ -250,7 +250,7 @@ export async function listModels(blob, cfg, fetchImpl) {
250
250
  export async function check(blob, cfg, fetchImpl) {
251
251
  const impl = fetchImpl || fetch
252
252
  const res = await impl(MODELS, { headers: { Authorization: "Bearer " + blob.accessToken, Accept: "application/json" } })
253
- if (!res.ok) throw httpError(res.status, await res.text())
253
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
254
254
  const json = await readJson(res)
255
255
  return { ok: true, raw: json }
256
256
  }
@@ -308,7 +308,7 @@ export async function deviceStart(cfg, fetchImpl) {
308
308
  },
309
309
  body: params.toString(),
310
310
  })
311
- if (!res.ok) throw httpError(res.status, await res.text().catch(() => ''), 'DEVICE')
311
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res), 'DEVICE')
312
312
  const json = await readJson(res)
313
313
  if (!json || typeof json.user_code !== 'string' || typeof json.device_code !== 'string') {
314
314
  throw httpError(502, 'xAI device authorization response is incomplete', 'DEVICE')
@@ -338,7 +338,7 @@ export async function devicePoll(cfg, session, fetchImpl) {
338
338
  body: params.toString(),
339
339
  })
340
340
  if (res.status === 400 || res.status === 403) {
341
- const rawText = await res.text().catch(() => '')
341
+ const rawText = await readErrorBody(res)
342
342
  let errJson = {}
343
343
  try {
344
344
  errJson = JSON.parse(rawText)
@@ -356,7 +356,7 @@ export async function devicePoll(cfg, session, fetchImpl) {
356
356
  }
357
357
  throw httpError(res.status, rawText, 'DEVICE')
358
358
  }
359
- if (!res.ok) throw httpError(res.status, await res.text().catch(() => ''), 'DEVICE')
359
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res), 'DEVICE')
360
360
  const json = await readJson(res)
361
361
  if (!json || typeof json.access_token !== 'string') {
362
362
  throw httpError(502, 'xAI device token response is incomplete', 'DEVICE')
@@ -391,7 +391,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
391
391
  body: JSON.stringify(body),
392
392
  signal,
393
393
  })
394
- if (!res.ok) throw httpError(res.status, await res.text())
394
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
395
395
  yield* codexResponsesStream(res.body)
396
396
  }
397
397
 
@@ -4,7 +4,7 @@
4
4
  import { LlmError } from '@deepseek-ai/dsh-llm'
5
5
  import { buildAuthorizeUrl } from '../oauth.js'
6
6
  import { openaiMessages, modelCatalog } from '../messages.js'
7
- import { jsonTokenRequest, tokenBlobFromOAuth, openaiChatStream, httpError, readJson } from '../wire.js'
7
+ import { jsonTokenRequest, tokenBlobFromOAuth, openaiChatStream, httpError, readJson , readErrorBody } from '../wire.js'
8
8
  import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
9
9
 
10
10
  export const id = 'jetbrains'
@@ -193,6 +193,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
193
193
  signal,
194
194
  })
195
195
 
196
- if (!res.ok) throw httpError(res.status, await res.text())
196
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
197
197
  yield* openaiChatStream(res.body)
198
198
  }
@@ -1,6 +1,6 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages, openaiTools, modelCatalog } from '../messages.js'
3
- import { openaiChatStream, readJson, httpError } from '../wire.js'
3
+ import { openaiChatStream, readJson, httpError , readErrorBody } from '../wire.js'
4
4
  import { asUsageSnapshot } from '../usage.js'
5
5
 
6
6
  export const id = 'kimi'
@@ -116,6 +116,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
116
116
  signal,
117
117
  })
118
118
 
119
- if (!res.ok) throw httpError(res.status, await res.text())
119
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
120
120
  yield* openaiChatStream(res.body)
121
121
  }
@@ -1,6 +1,6 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages, openaiTools, modelCatalog } from '../messages.js'
3
- import { openaiChatStream, readJson, httpError } from '../wire.js'
3
+ import { openaiChatStream, readJson, httpError , readErrorBody } from '../wire.js'
4
4
  import { asUsageSnapshot } from '../usage.js'
5
5
 
6
6
  export const id = 'kiro'
@@ -112,6 +112,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
112
112
  signal,
113
113
  })
114
114
 
115
- if (!res.ok) throw httpError(res.status, await res.text())
115
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
116
116
  yield* openaiChatStream(res.body)
117
117
  }
@@ -4,7 +4,7 @@
4
4
  import { LlmError } from '@deepseek-ai/dsh-llm'
5
5
  import { buildAuthorizeUrl } from '../oauth.js'
6
6
  import { openaiMessages, modelCatalog } from '../messages.js'
7
- import { jsonTokenRequest, tokenBlobFromOAuth, openaiChatStream, httpError, readJson } from '../wire.js'
7
+ import { jsonTokenRequest, tokenBlobFromOAuth, openaiChatStream, httpError, readJson , readErrorBody } from '../wire.js'
8
8
  import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
9
9
 
10
10
  export const id = 'perplexity'
@@ -205,6 +205,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
205
205
  signal,
206
206
  })
207
207
 
208
- if (!res.ok) throw httpError(res.status, await res.text())
208
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
209
209
  yield* openaiChatStream(res.body)
210
210
  }
@@ -1,6 +1,6 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages, openaiTools } from '../messages.js'
3
- import { openaiChatStream, httpError } from '../wire.js'
3
+ import { openaiChatStream, httpError , readErrorBody } from '../wire.js'
4
4
 
5
5
  export const id = 'qwen'
6
6
  const QWEN_API_BASE = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
@@ -81,6 +81,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
81
81
  signal
82
82
  })
83
83
 
84
- if (!res.ok) throw httpError(res.status, await res.text())
84
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
85
85
  yield* openaiChatStream(res.body)
86
86
  }
@@ -1,6 +1,6 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages } from '../messages.js'
3
- import { openaiChatStream, httpError } from '../wire.js'
3
+ import { openaiChatStream, httpError , readErrorBody } from '../wire.js'
4
4
 
5
5
  export const id = 'replit'
6
6
  const REPLIT_API_BASE = 'https://replit.com/api/v1/ai'
@@ -64,6 +64,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
64
64
  signal
65
65
  })
66
66
 
67
- if (!res.ok) throw httpError(res.status, await res.text())
67
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
68
68
  yield* openaiChatStream(res.body)
69
69
  }
@@ -1,6 +1,6 @@
1
1
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
2
  import { openaiMessages } from '../messages.js'
3
- import { openaiChatStream, httpError } from '../wire.js'
3
+ import { openaiChatStream, httpError , readErrorBody } from '../wire.js'
4
4
 
5
5
  export const id = 'spark'
6
6
  const SPARK_API_BASE = 'https://spark-api-open.xf-yun.com/v1'
@@ -68,6 +68,6 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
68
68
  signal
69
69
  })
70
70
 
71
- if (!res.ok) throw httpError(res.status, await res.text())
71
+ if (!res.ok) throw httpError(res.status, await readErrorBody(res))
72
72
  yield* openaiChatStream(res.body)
73
73
  }
package/lib/wire.js CHANGED
@@ -432,4 +432,30 @@ export async function jsonTokenRequest(url, body, fetchImpl, headers, { timeoutM
432
432
  body: JSON.stringify(body),
433
433
  }, { timeoutMs })
434
434
  return readJson(res)
435
- }
435
+ }
436
+
437
+ export async function readErrorBody(res, maxChars = 4096) {
438
+ if (!res || typeof res !== 'object') return ''
439
+ try {
440
+ if (!res.body || typeof res.body.getReader !== 'function') {
441
+ const text = await res.text()
442
+ return String(text || '').slice(0, maxChars)
443
+ }
444
+ const reader = res.body.getReader()
445
+ const decoder = new TextDecoder()
446
+ let out = ''
447
+ try {
448
+ while (out.length < maxChars) {
449
+ const { done, value } = await reader.read()
450
+ if (done) break
451
+ out += decoder.decode(value, { stream: true })
452
+ }
453
+ out += decoder.decode()
454
+ } finally {
455
+ if (typeof reader.cancel === 'function') await reader.cancel().catch(() => {})
456
+ }
457
+ return out.slice(0, maxChars)
458
+ } catch {
459
+ return ''
460
+ }
461
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.84",
3
+ "version": "0.6.86",
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",