@goodandready/dsh-subscriptions 0.6.40 → 0.6.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/http.js CHANGED
@@ -81,7 +81,7 @@ export function isTrustedSettingsRequest(request) {
81
81
  if (secFetchSite === 'cross-site') return false
82
82
 
83
83
  // 2. If origin header is present, it must match host
84
- const host = headers.host || headers['x-forwarded-host']
84
+ const host = String(Array.isArray(headers.host) ? headers.host[0] : (headers.host || headers['x-forwarded-host'] || ''))
85
85
  const origin = headers.origin
86
86
  if (origin) {
87
87
  try {
@@ -167,17 +167,36 @@ export function sanitizeRequestHeaders(url, init = {}, options = {}) {
167
167
  delete headers.Cookie
168
168
  }
169
169
  }
170
- } catch {}
170
+ } catch (err) {
171
+ void err
172
+ return headers
173
+ }
171
174
  return headers
172
175
  }
173
176
 
174
- export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs = 30000, allowedOrigins } = {}) {
177
+ export function isTimeoutError(err) {
178
+ if (!err) return false
179
+ return err.name === 'TimeoutError'
180
+ || err.code === 'ETIMEDOUT'
181
+ || (err.name === 'AbortError' && String(err.message || '').toLowerCase().includes('timeout'))
182
+ || String(err.message || '').toLowerCase().includes('aborted due to timeout')
183
+ || String(err.message || '').toLowerCase().includes('timed out')
184
+ || String(err.message || '').toLowerCase().includes('operation was aborted')
185
+ }
186
+
187
+ /**
188
+ * @param {any} fetchImpl
189
+ * @param {string} url
190
+ * @param {any} [init]
191
+ * @param {{ timeoutMs?: number, allowedOrigins?: string[], stream?: boolean }} [options]
192
+ */
193
+ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs = 30000, allowedOrigins, stream = false } = {}) {
175
194
  const safeHeaders = sanitizeRequestHeaders(url, init, { allowedOrigins })
176
195
  init = { ...init, headers: safeHeaders }
177
196
  const impl = fetchImpl || fetch
178
197
  if (!timeoutMs || timeoutMs <= 0) return impl(url, init)
179
198
  const controller = new AbortController()
180
- const timer = setTimeout(() => {
199
+ let timer = setTimeout(() => {
181
200
  controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
182
201
  }, timeoutMs)
183
202
 
@@ -186,9 +205,74 @@ export async function fetchWithTimeout(fetchImpl, url, init = {}, { timeoutMs =
186
205
  : controller.signal
187
206
 
188
207
  try {
189
- return await impl(url, { ...init, signal })
190
- } finally {
208
+ const res = await impl(url, { ...init, signal })
209
+ if (stream) {
210
+ clearTimeout(timer)
211
+ return res
212
+ }
213
+ const origJson = typeof res.json === 'function' ? res.json.bind(res) : null
214
+ const origText = typeof res.text === 'function' ? res.text.bind(res) : null
215
+ if (origJson) {
216
+ res.json = () => {
217
+ if (controller.signal.aborted) {
218
+ return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
219
+ }
220
+ return new Promise((resolve, reject) => {
221
+ const onAbort = () => reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
222
+ controller.signal.addEventListener('abort', onAbort, { once: true })
223
+ origJson().then(
224
+ (val) => {
225
+ controller.signal.removeEventListener('abort', onAbort)
226
+ clearTimeout(timer)
227
+ resolve(val)
228
+ },
229
+ (err) => {
230
+ controller.signal.removeEventListener('abort', onAbort)
231
+ clearTimeout(timer)
232
+ if (controller.signal.aborted) {
233
+ reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
234
+ } else {
235
+ reject(err)
236
+ }
237
+ }
238
+ )
239
+ })
240
+ }
241
+ }
242
+ if (origText) {
243
+ res.text = () => {
244
+ if (controller.signal.aborted) {
245
+ return Promise.reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
246
+ }
247
+ return new Promise((resolve, reject) => {
248
+ const onAbort = () => reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
249
+ controller.signal.addEventListener('abort', onAbort, { once: true })
250
+ origText().then(
251
+ (val) => {
252
+ controller.signal.removeEventListener('abort', onAbort)
253
+ clearTimeout(timer)
254
+ resolve(val)
255
+ },
256
+ (err) => {
257
+ controller.signal.removeEventListener('abort', onAbort)
258
+ clearTimeout(timer)
259
+ if (controller.signal.aborted) {
260
+ reject(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
261
+ } else {
262
+ reject(err)
263
+ }
264
+ }
265
+ )
266
+ })
267
+ }
268
+ }
269
+ return res
270
+ } catch (err) {
191
271
  clearTimeout(timer)
272
+ if (controller.signal.aborted && !isTimeoutError(err)) {
273
+ throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
274
+ }
275
+ throw err
192
276
  }
193
277
  }
194
278
 
@@ -39,6 +39,7 @@ async function discoverLocalCliSessionsInner() {
39
39
  const tok = codex.tokens || codex
40
40
  detected.codex = {
41
41
  provider: 'codex',
42
+ source: 'file',
42
43
  path: homeFile('.codex', 'auth.json'),
43
44
  email: codex.email || codex.account || '',
44
45
  hasRefreshToken: !!(tok.refresh_token || tok.refreshToken),
@@ -54,6 +55,7 @@ async function discoverLocalCliSessionsInner() {
54
55
  if (grok && (grok.access_token || grok.token || (grok.tokens && grok.tokens.access_token))) {
55
56
  detected.grok = {
56
57
  provider: 'grok',
58
+ source: 'file',
57
59
  path: p,
58
60
  email: grok.email || grok.account || '',
59
61
  hasRefreshToken: !!(grok.refresh_token || (grok.tokens && grok.tokens.refresh_token)),
@@ -73,6 +75,7 @@ async function discoverLocalCliSessionsInner() {
73
75
  if (tok && (tok.access_token || tok.accessToken)) {
74
76
  detected.antigravity = {
75
77
  provider: 'antigravity',
78
+ source: 'file',
76
79
  path: p,
77
80
  email: agy.email || tok.account || '',
78
81
  hasRefreshToken: !!(tok.refresh_token || tok.refreshToken),
@@ -86,6 +89,7 @@ async function discoverLocalCliSessionsInner() {
86
89
  if (kimi && (kimi.access_token || kimi.token)) {
87
90
  detected.kimi = {
88
91
  provider: 'kimi',
92
+ source: 'file',
89
93
  path: homeFile('.kimi-code', 'credentials', 'kimi-code.json'),
90
94
  email: kimi.email || kimi.account || '',
91
95
  hasRefreshToken: !!(kimi.refresh_token || kimi.refreshToken),
@@ -103,6 +107,7 @@ async function discoverLocalCliSessionsInner() {
103
107
  if (glm && (glm.apiKey || glm.api_key || (glm.provider && glm.provider.apiKey))) {
104
108
  detected.glm = {
105
109
  provider: 'glm',
110
+ source: 'file',
106
111
  path: p,
107
112
  email: 'ZCode CLI Account',
108
113
  hasRefreshToken: false,
@@ -111,20 +116,31 @@ async function discoverLocalCliSessionsInner() {
111
116
  }
112
117
  }
113
118
 
114
- // 6. Cursor Token (env, CLI config or IDE state)
119
+ // 6. Cursor Token (CLI config, auth.json, or env)
115
120
  const cursorCliConfig = await readJson(homeFile('.cursor', 'cli-config.json'))
116
- if (process.env.CURSOR_ACCESS_TOKEN) {
121
+ const cursorAuthConfig = await readJson(homeFile('.cursor', 'auth.json'))
122
+ if (cursorCliConfig && (cursorCliConfig.authInfo || cursorCliConfig.serverConfigCache)) {
117
123
  detected.cursor = {
118
124
  provider: 'cursor',
119
- path: 'CURSOR_ACCESS_TOKEN env',
120
- email: 'Cursor IDE User',
125
+ source: 'file',
126
+ path: homeFile('.cursor', 'cli-config.json'),
127
+ email: (cursorCliConfig.authInfo && cursorCliConfig.authInfo.email) || 'Cursor CLI User',
121
128
  hasRefreshToken: false,
122
129
  }
123
- } else if (cursorCliConfig && (cursorCliConfig.authInfo || cursorCliConfig.serverConfigCache)) {
130
+ } else if (cursorAuthConfig && (cursorAuthConfig.accessToken || cursorAuthConfig.access_token || (cursorAuthConfig.authInfo && cursorAuthConfig.authInfo.authId))) {
124
131
  detected.cursor = {
125
132
  provider: 'cursor',
126
- path: homeFile('.cursor', 'cli-config.json'),
127
- email: (cursorCliConfig.authInfo && cursorCliConfig.authInfo.email) || 'Cursor CLI User',
133
+ source: 'file',
134
+ path: homeFile('.cursor', 'auth.json'),
135
+ email: (cursorAuthConfig.authInfo && cursorAuthConfig.authInfo.email) || cursorAuthConfig.email || 'Cursor User',
136
+ hasRefreshToken: false,
137
+ }
138
+ } else if (process.env.CURSOR_ACCESS_TOKEN) {
139
+ detected.cursor = {
140
+ provider: 'cursor',
141
+ source: 'env',
142
+ path: 'CURSOR_ACCESS_TOKEN',
143
+ email: 'Cursor IDE User',
128
144
  hasRefreshToken: false,
129
145
  }
130
146
  }
@@ -139,6 +155,7 @@ async function discoverLocalCliSessionsInner() {
139
155
  if (k && (k.accessToken || k.access_token || k.token)) {
140
156
  detected.kiro = {
141
157
  provider: 'kiro',
158
+ source: 'file',
142
159
  path: p,
143
160
  email: k.email || k.account || 'AWS Kiro User',
144
161
  hasRefreshToken: !!(k.refreshToken || k.refresh_token),
@@ -149,7 +166,8 @@ async function discoverLocalCliSessionsInner() {
149
166
  if (!detected.kiro && process.env.KIRO_API_KEY) {
150
167
  detected.kiro = {
151
168
  provider: 'kiro',
152
- path: 'KIRO_API_KEY env',
169
+ source: 'env',
170
+ path: 'KIRO_API_KEY',
153
171
  email: 'AWS Kiro API Key',
154
172
  hasRefreshToken: false,
155
173
  }
@@ -158,12 +176,15 @@ async function discoverLocalCliSessionsInner() {
158
176
  // 8. Claude CLI Direct Import
159
177
  const claudeCreds = await readClaudeCredentials(activeHome)
160
178
  if (claudeCreds) {
161
- detected['claude-cli'] = {
179
+ const claudeEntry = {
162
180
  provider: 'claude',
181
+ source: 'file',
163
182
  path: homeFile('.claude', 'credentials.json'),
164
- email: 'Claude Code CLI User',
183
+ email: claudeCreds.email || 'Claude Code CLI User',
165
184
  hasRefreshToken: !!claudeCreds.refreshToken,
166
185
  }
186
+ detected.claude = claudeEntry
187
+ detected['claude-cli'] = claudeEntry
167
188
  }
168
189
 
169
190
  // 9. GitHub Copilot token
@@ -179,6 +200,7 @@ async function discoverLocalCliSessionsInner() {
179
200
  if (tok) {
180
201
  detected.copilot = {
181
202
  provider: 'copilot',
203
+ source: 'file',
182
204
  path: p,
183
205
  email: gh.user || 'GitHub Copilot User',
184
206
  hasRefreshToken: false,
@@ -190,37 +212,37 @@ async function discoverLocalCliSessionsInner() {
190
212
  if (!detected.copilot && (process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN)) {
191
213
  detected.copilot = {
192
214
  provider: 'copilot',
193
- path: 'GITHUB_COPILOT_TOKEN env',
215
+ source: 'env',
216
+ path: process.env.GITHUB_COPILOT_TOKEN ? 'GITHUB_COPILOT_TOKEN' : 'GH_TOKEN',
194
217
  email: 'GitHub Copilot User',
195
218
  hasRefreshToken: false,
196
219
  }
197
220
  }
198
221
 
199
-
200
-
201
222
  return detected
202
223
  }
203
224
 
204
225
  export async function loadLocalCliBlob(provider, options = {}) {
226
+ const canonical = (provider === 'claude-cli') ? 'claude' : provider
205
227
  const discovered = await discoverLocalCliSessions(options)
206
- const info = discovered[provider]
228
+ const info = discovered[canonical] || discovered[provider]
207
229
  if (!info) throw new Error(`no local CLI session found for ${provider}`)
208
230
 
209
- if (provider === 'claude-cli' || (provider === 'claude' && info.provider === 'claude')) {
231
+ if (canonical === 'claude') {
210
232
  const creds = await readClaudeCredentials(options && options.home)
211
233
  if (!creds) throw new Error('Claude CLI credentials not found')
212
234
  return {
213
235
  accessToken: creds.accessToken,
214
- refreshToken: creds.refreshToken,
236
+ refreshToken: creds.refreshToken || '',
215
237
  expiresAt: creds.expiresAt,
216
- email: 'Claude Code CLI User'
238
+ email: creds.email || 'Claude Code CLI User',
217
239
  }
218
240
  }
219
241
 
220
- if (provider === 'copilot') {
242
+ if (canonical === 'copilot') {
221
243
  let tok = process.env.GITHUB_COPILOT_TOKEN || process.env.GH_TOKEN || ''
222
244
  let email = 'GitHub Copilot User'
223
- if (info.path && info.path !== 'GITHUB_COPILOT_TOKEN env') {
245
+ if (info.source === 'file' && info.path) {
224
246
  const cp = await readJson(info.path)
225
247
  if (cp) {
226
248
  const gh = cp['github.com'] || cp
@@ -228,6 +250,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
228
250
  email = gh.user || email
229
251
  }
230
252
  }
253
+ if (!tok) throw new Error('no Copilot token found')
231
254
  return {
232
255
  accessToken: tok,
233
256
  refreshToken: tok,
@@ -238,10 +261,54 @@ export async function loadLocalCliBlob(provider, options = {}) {
238
261
  }
239
262
  }
240
263
 
264
+ if (canonical === 'cursor') {
265
+ if (info.source === 'env' || !info.path || info.path.includes('env') || info.path === 'CURSOR_ACCESS_TOKEN') {
266
+ const token = process.env.CURSOR_ACCESS_TOKEN
267
+ if (!token) throw new Error('CURSOR_ACCESS_TOKEN environment variable not set')
268
+ return {
269
+ accessToken: token,
270
+ refreshToken: '',
271
+ expiresAt: Date.now() + 30 * 86400 * 1000,
272
+ email: 'Cursor IDE User',
273
+ }
274
+ }
275
+ const raw = await readJson(info.path)
276
+ if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
277
+ const token = (raw && (raw.accessToken || raw.access_token || (raw.authInfo && raw.authInfo.authId))) || process.env.CURSOR_ACCESS_TOKEN || ''
278
+ return {
279
+ accessToken: token,
280
+ refreshToken: '',
281
+ expiresAt: Date.now() + 30 * 86400 * 1000,
282
+ email: (raw && raw.authInfo && raw.authInfo.email) || raw.email || 'Cursor User',
283
+ }
284
+ }
285
+
286
+ if (canonical === 'kiro') {
287
+ if (info.source === 'env' || !info.path || info.path.includes('env') || info.path === 'KIRO_API_KEY') {
288
+ const token = process.env.KIRO_API_KEY
289
+ if (!token) throw new Error('KIRO_API_KEY environment variable not set')
290
+ return {
291
+ accessToken: token,
292
+ refreshToken: '',
293
+ expiresAt: Date.now() + 30 * 86400 * 1000,
294
+ email: 'AWS Kiro API Key',
295
+ }
296
+ }
297
+ const raw = await readJson(info.path)
298
+ if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
299
+ const token = (raw && (raw.accessToken || raw.access_token || raw.token)) || process.env.KIRO_API_KEY || ''
300
+ return {
301
+ accessToken: token,
302
+ refreshToken: (raw && (raw.refreshToken || raw.refresh_token)) || '',
303
+ expiresAt: (raw && raw.expiresAt) || (Date.now() + 30 * 86400 * 1000),
304
+ email: (raw && raw.email) || 'AWS Kiro User',
305
+ }
306
+ }
307
+
241
308
  const raw = await readJson(info.path)
242
309
  if (!raw) throw new Error(`failed to read local CLI file: ${info.path}`)
243
310
 
244
- if (provider === 'codex') {
311
+ if (canonical === 'codex') {
245
312
  const tok = raw.tokens || raw
246
313
  return {
247
314
  accessToken: tok.access_token || tok.accessToken || '',
@@ -252,7 +319,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
252
319
  }
253
320
  }
254
321
 
255
- if (provider === 'grok') {
322
+ if (canonical === 'grok') {
256
323
  const tok = raw.tokens || raw
257
324
  return {
258
325
  accessToken: tok.access_token || tok.token || '',
@@ -262,7 +329,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
262
329
  }
263
330
  }
264
331
 
265
- if (provider === 'antigravity') {
332
+ if (canonical === 'antigravity') {
266
333
  const tok = raw.token || raw
267
334
  return {
268
335
  accessToken: tok.access_token || tok.accessToken || '',
@@ -273,7 +340,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
273
340
  }
274
341
  }
275
342
 
276
- if (provider === 'kimi') {
343
+ if (canonical === 'kimi') {
277
344
  return {
278
345
  accessToken: raw.access_token || raw.token || '',
279
346
  refreshToken: raw.refresh_token || '',
@@ -282,7 +349,7 @@ export async function loadLocalCliBlob(provider, options = {}) {
282
349
  }
283
350
  }
284
351
 
285
- if (provider === 'glm') {
352
+ if (canonical === 'glm') {
286
353
  const apiKey = raw.apiKey || raw.api_key || (raw.provider && raw.provider.apiKey) || ''
287
354
  return {
288
355
  accessToken: apiKey,
@@ -292,25 +359,5 @@ export async function loadLocalCliBlob(provider, options = {}) {
292
359
  }
293
360
  }
294
361
 
295
- if (provider === 'cursor') {
296
- const token = process.env.CURSOR_ACCESS_TOKEN || (raw && (raw.accessToken || raw.access_token || (raw.authInfo && raw.authInfo.authId))) || ''
297
- return {
298
- accessToken: token,
299
- refreshToken: '',
300
- expiresAt: Date.now() + 30 * 86400 * 1000,
301
- email: (raw && raw.authInfo && raw.authInfo.email) || 'Cursor User',
302
- }
303
- }
304
-
305
- if (provider === 'kiro') {
306
- const token = process.env.KIRO_API_KEY || (raw && (raw.accessToken || raw.access_token || raw.token)) || ''
307
- return {
308
- accessToken: token,
309
- refreshToken: (raw && (raw.refreshToken || raw.refresh_token)) || '',
310
- expiresAt: (raw && raw.expiresAt) || (Date.now() + 30 * 86400 * 1000),
311
- email: (raw && raw.email) || 'AWS Kiro User',
312
- }
313
- }
314
-
315
362
  throw new Error(`unsupported local CLI import for provider ${provider}`)
316
363
  }
package/lib/oauth.js CHANGED
@@ -1,3 +1,15 @@
1
+ /**
2
+ * @param {{
3
+ * authUrl: string,
4
+ * clientId: string,
5
+ * redirectUri: string,
6
+ * challenge: string,
7
+ * state: string,
8
+ * extra?: Record<string, any>,
9
+ * scope?: string,
10
+ * }} params
11
+ * @returns {string}
12
+ */
1
13
  export function buildAuthorizeUrl({
2
14
  authUrl,
3
15
  clientId,
@@ -143,7 +143,9 @@ export function registerAccountsRoutes(ctx, state) {
143
143
  } catch {
144
144
  body = null
145
145
  }
146
- if (!body || !body.provider || !isProvider(body.provider)) {
146
+ const rawProv = body && body.provider
147
+ const prov = (rawProv === 'claude-cli') ? 'claude' : rawProv
148
+ if (!prov || !isProvider(prov)) {
147
149
  writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'unknown or missing provider' } })
148
150
  return
149
151
  }
@@ -153,9 +155,8 @@ export function registerAccountsRoutes(ctx, state) {
153
155
  return
154
156
  }
155
157
  try {
156
- const blob = await loadLocalCliBlob(body.provider)
157
- const prov = body.provider
158
- const idx = Number(body.index) || 1
158
+ const blob = await loadLocalCliBlob(prov)
159
+ const idx = idxNum
159
160
  const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
160
161
  const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
161
162
  if (!exists && getSettingsApi()) {
@@ -2,7 +2,7 @@
2
2
  import { getRecentAlerts, clearAlerts } from '../alerts.js'
3
3
  import { normalizeSlots, vendorConfig } from '../accounts.js'
4
4
  import { Config, plainConfig, publicConfig, restoreMaskedSecrets } from '../config-schema.js'
5
- import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
5
+ import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson, fetchWithTimeout, isTimeoutError } from '../http.js'
6
6
  import { quotaSnapshot } from '../ratelimit.js'
7
7
  import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
8
8
  import { getVendor } from '../vendors/index.js'
@@ -257,10 +257,8 @@ export function registerStatusRoutes(ctx, state) {
257
257
  }
258
258
  let capturedQuota = null
259
259
  const probeFetch = async (url, init = {}) => {
260
- const timeoutSignal = AbortSignal.timeout(15_000)
261
- const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
262
260
  const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
263
- const res2 = await doFetch(url, { ...init, signal })
261
+ const res2 = await fetchWithTimeout(doFetch, url, init, { timeoutMs: 15_000 })
264
262
  try {
265
263
  const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
266
264
  if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
@@ -272,12 +270,14 @@ export function registerStatusRoutes(ctx, state) {
272
270
  const latencyMs = Date.now() - t0;
273
271
  writeJson(res, 200, { ok: true, provider, index: index, ref, latencyMs, email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null, quota: capturedQuota || info.quota || null, usagePercent: info.usagePercent ?? null })
274
272
  } catch (e) {
273
+ const isTimeout = isTimeoutError(e)
275
274
  writeJson(res, 200, {
276
275
  ok: false, provider, index: index, ref,
277
276
  latencyMs: Date.now() - t0,
278
277
  email: pmE(blob.email), label: pmL(blob.label), expiresAt: blob.expiresAt || null,
279
278
  quota: capturedQuota || info.quota || null,
280
- error: { code: e && e.code ? e.code : 'VENDOR', message: String(e && e.message || e).slice(0, 300) },
279
+ isTimeout,
280
+ error: { code: isTimeout ? 'TIMEOUT' : (e && e.code ? e.code : 'VENDOR'), message: String(e && e.message || e).slice(0, 300) },
281
281
  })
282
282
  }
283
283
  }),
@@ -384,10 +384,8 @@ export function registerStatusRoutes(ctx, state) {
384
384
  if (typeof vendor.check === 'function') {
385
385
  let capturedQuota = null
386
386
  const probeFetch = async (url, init = {}) => {
387
- const timeoutSignal = AbortSignal.timeout(15_000)
388
- const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal
389
387
  const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
390
- const res2 = await doFetch(url, { ...init, signal })
388
+ const res2 = await fetchWithTimeout(doFetch, url, init, { timeoutMs: 15_000 })
391
389
  try {
392
390
  const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
393
391
  if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
@@ -68,6 +68,11 @@ export class RunawayDetector {
68
68
  }
69
69
  }
70
70
 
71
+ /**
72
+ * @param {AsyncIterable<any>} stream
73
+ * @param {Record<string, any>} [options]
74
+ * @returns {AsyncGenerator<any, void, unknown>}
75
+ */
71
76
  export async function* withRunawayGuard(stream, options = {}) {
72
77
  if (options && options.enabled === false) {
73
78
  yield* stream
@@ -79,7 +84,7 @@ export async function* withRunawayGuard(stream, options = {}) {
79
84
  for await (const chunk of stream) {
80
85
  if (chunk && chunk.type === 'finish') {
81
86
  yield chunk
82
- return
87
+ continue
83
88
  }
84
89
 
85
90
  const text = (chunk && typeof chunk === 'object')
@@ -88,6 +93,13 @@ export async function* withRunawayGuard(stream, options = {}) {
88
93
 
89
94
  if (text && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || !chunk.type)) {
90
95
  if (detector.feed(text)) {
96
+ if (options && typeof options === 'object') {
97
+ options.tripped = true
98
+ options.trippedReason = detector.getReason()
99
+ if (typeof options.onRunaway === 'function') {
100
+ options.onRunaway(detector.getReason())
101
+ }
102
+ }
91
103
  yield {
92
104
  type: 'finish',
93
105
  reason: {
@@ -2,10 +2,8 @@ const sessionPins = new Map()
2
2
  const DEFAULT_TTL_MS = 30 * 60 * 1000 // 30 mins
3
3
 
4
4
  /**
5
- * Bind a session id to an account ref for ttlMs (default 30 minutes).
6
- * @param {string|undefined} sessionId
7
- * @param {string|undefined} accountRef
8
- * @param {number} [ttlMs]
5
+ * Prune expired session pin entries.
6
+ * @param {number} [now]
9
7
  * @returns {void}
10
8
  */
11
9
  export function pruneSessionPins(now = Date.now()) {
@@ -16,6 +14,13 @@ export function pruneSessionPins(now = Date.now()) {
16
14
  }
17
15
  }
18
16
 
17
+ /**
18
+ * Bind a session id to an account ref for ttlMs (default 30 minutes).
19
+ * @param {string|undefined} sessionId
20
+ * @param {string|undefined} accountRef
21
+ * @param {number} [ttlMs]
22
+ * @returns {void}
23
+ */
19
24
  export function pinSession(sessionId, accountRef, ttlMs = DEFAULT_TTL_MS) {
20
25
  if (!sessionId || !accountRef) return
21
26
  if (sessionPins.size >= 50) {
package/lib/sse.js CHANGED
@@ -1,3 +1,8 @@
1
+ /**
2
+ * @param {any} body
3
+ * @param {{ idleTimeoutMs?: number, signal?: AbortSignal }} [options]
4
+ * @returns {AsyncGenerator<string, void, unknown>}
5
+ */
1
6
  export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {}) {
2
7
  const reader = body && typeof body.getReader === 'function' ? body.getReader() : null
3
8
  const decoder = new TextDecoder()
@@ -38,7 +43,7 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
38
43
  const timeoutPromise = new Promise((_, reject) => {
39
44
  if (!idleTimeoutMs || idleTimeoutMs <= 0) return
40
45
  timer = setTimeout(() => {
41
- const err = new Error(`stream idle timeout: no data received for ${Math.round(idleTimeoutMs / 1000)}s`)
46
+ const err = /** @type {Error & { code?: string }} */ (new Error(`stream idle timeout: no data received for ${Math.round(idleTimeoutMs / 1000)}s`))
42
47
  err.code = 'TIMEOUT'
43
48
  reject(err)
44
49
  }, idleTimeoutMs)
@@ -41,7 +41,7 @@ export async function* streamWithRotation({
41
41
  return
42
42
  }
43
43
  if (lastError) throw lastError
44
- const err = new Error('no usable subscription account for this provider')
44
+ const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
45
45
  err.code = 'RATE_LIMIT'
46
46
  throw err
47
47
  }
@@ -53,7 +53,18 @@ export async function* streamWithRotation({
53
53
 
54
54
  while (true) {
55
55
  try {
56
- const guardedStream = withRunawayGuard(streamOnce(account, options), options && options.runawayGuard)
56
+ const runawayState = options && options._runawayState ? options._runawayState : { tripped: false, reason: null }
57
+ const runawayOpts = {
58
+ ...(options && options.runawayGuard),
59
+ onRunaway: (reason) => {
60
+ runawayState.tripped = true
61
+ runawayState.reason = reason
62
+ },
63
+ }
64
+ const guardedStream = withRunawayGuard(
65
+ streamOnce(account, { ...options, _runawayState: runawayState }),
66
+ runawayOpts
67
+ )
57
68
  for await (const chunk of guardedStream) {
58
69
  firstChunkDelivered = true
59
70
  yield chunk
@@ -13,8 +13,8 @@ import { codexResponsesBody, modelCatalog } from "./messages.js"
13
13
  import { formTokenRequest, jsonTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError } from "./wire.js"
14
14
  import { emailFromToken } from "./jwt.js"
15
15
 
16
- function defaultModels(models) {
17
- return modelCatalog("custom", (models || []).map((row) => (typeof row === "string" ? { id: row, name: row } : row)))
16
+ function defaultModels(providerId, models) {
17
+ return modelCatalog(providerId, (models || []).map((row) => (typeof row === "string" ? { id: row, name: row } : row)))
18
18
  }
19
19
 
20
20
  /**
@@ -69,7 +69,8 @@ export function createVendorFromProfile(profile) {
69
69
  code_verifier: pkce.verifier,
70
70
  }
71
71
  const req = tokenStyle === "json" ? jsonTokenRequest : formTokenRequest
72
- const json = await req(profile.tokenUrl, params, fetchImpl)
72
+ const tokenFetch = (u, i, o) => fetchWithTimeout(fetchImpl || fetch, u, i, { timeoutMs: 30000, ...(o || {}) })
73
+ const json = await req(profile.tokenUrl, params, tokenFetch)
73
74
  const blob = tokenBlobFromOAuth(json)
74
75
  return { ...blob, email: blob.email || emailFromToken(blob.accessToken), label: blob.label || name }
75
76
  },
@@ -80,11 +81,12 @@ export function createVendorFromProfile(profile) {
80
81
  refresh_token: blob.refreshToken,
81
82
  }
82
83
  const req = tokenStyle === "json" ? jsonTokenRequest : formTokenRequest
83
- const json = await req(profile.tokenUrl, params, fetchImpl)
84
+ const tokenFetch = (u, i, o) => fetchWithTimeout(fetchImpl || fetch, u, i, { timeoutMs: 30000, ...(o || {}) })
85
+ const json = await req(profile.tokenUrl, params, tokenFetch)
84
86
  return tokenBlobFromOAuth(json, { label: blob.label, email: blob.email })
85
87
  },
86
88
  async listModels(blob, cfg, fetchImpl) {
87
- const catalog = defaultModels(cfg.models || profile.models || [])
89
+ const catalog = defaultModels(id, cfg.models || profile.models || [])
88
90
  if (!profile.modelsPath) return catalog
89
91
  try {
90
92
  const res = await fetchWithTimeout(fetchImpl || fetch, `${base}${profile.modelsPath}`, {
@@ -112,7 +114,7 @@ export function createVendorFromProfile(profile) {
112
114
  async* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
113
115
  const body = codexResponsesBody(options, "")
114
116
  if (!body.instructions) delete body.instructions
115
- const res = await (fetchImpl || fetch)(`${base}/responses`, {
117
+ const res = await fetchWithTimeout(fetchImpl || fetch, `${base}/responses`, {
116
118
  method: "POST",
117
119
  headers: {
118
120
  ...headers,
@@ -122,7 +124,7 @@ export function createVendorFromProfile(profile) {
122
124
  },
123
125
  body: JSON.stringify(body),
124
126
  signal,
125
- })
127
+ }, { timeoutMs: 60000, stream: true })
126
128
  if (!res.ok) throw httpError(res.status, await res.text())
127
129
  yield* codexResponsesStream(res.body)
128
130
  },