@goodandready/dsh-subscriptions 0.6.38 → 0.6.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/accounts.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { needsWarmupProbe, enterProbing, resolveWarmupSuccess, resolveWarmupFailure, STATUS_QUARANTINE, STATUS_PROBING } from "./quarantine.js"
1
2
  import { fetchWithTimeout } from "./http.js"
2
3
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
3
4
  import { oauthRef, isProvider } from './refs.js'
@@ -348,19 +349,51 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
348
349
  quarantines.delete(ref)
349
350
  if (health.has(ref)) health.set(ref, null)
350
351
  },
351
- rememberQuarantine(ref, reason, until) {
352
+ rememberQuarantine(ref, reason, until, attempts = 1) {
352
353
  if (!ref) return
353
- quarantines.set(ref, { reason: reason || 'RATE_LIMIT', until: Number(until) || (Date.now() + 3600000) })
354
+ quarantines.set(ref, {
355
+ reason: reason || 'RATE_LIMIT',
356
+ until: Number(until) || (Date.now() + 3600000),
357
+ attempts: Number(attempts) || 1,
358
+ status: STATUS_QUARANTINE,
359
+ })
354
360
  },
355
- getQuarantine(ref) {
361
+ getQuarantine(ref, now = Date.now()) {
356
362
  const q = quarantines.get(ref)
357
363
  if (!q) return null
358
- if (Number(q.until) <= Date.now()) {
364
+ if (Number(q.until) <= now) {
365
+ if (q.status === STATUS_PROBING) return q
359
366
  quarantines.delete(ref)
360
367
  return null
361
368
  }
362
369
  return q
363
370
  },
371
+ async probeWarmup(ref, probeFn) {
372
+ const q = quarantines.get(ref)
373
+ if (!q) return { success: true }
374
+ const acc = { quarantineReason: q.reason, quarantineUntil: q.until, quarantineAttempts: q.attempts || 1, status: q.status }
375
+ if (!needsWarmupProbe(acc)) {
376
+ return { success: false, reason: 'NOT_EXPIRED' }
377
+ }
378
+ quarantines.set(ref, enterProbing(acc))
379
+ try {
380
+ if (typeof probeFn === 'function') {
381
+ await probeFn(ref)
382
+ }
383
+ resolveWarmupSuccess(acc)
384
+ quarantines.delete(ref)
385
+ return { success: true }
386
+ } catch (err) {
387
+ const updated = resolveWarmupFailure(acc, err?.message || q.reason)
388
+ quarantines.set(ref, {
389
+ reason: updated.quarantineReason,
390
+ until: updated.quarantineUntil,
391
+ attempts: updated.quarantineAttempts,
392
+ status: updated.status,
393
+ })
394
+ return { success: false, account: updated, error: err }
395
+ }
396
+ },
364
397
  releaseQuarantine(ref) {
365
398
  quarantines.delete(ref)
366
399
  },
@@ -21,6 +21,7 @@ export function createAdapterManager({
21
21
  }) {
22
22
  let handle
23
23
  let adapterDisposed = false
24
+ let retryTimer
24
25
  let registeredByUs = new Set()
25
26
 
26
27
  function disposeAdapterHandle(h) {
@@ -48,6 +49,11 @@ export function createAdapterManager({
48
49
  saveBlob: (ref, blob) => store.saveBlob(ref, blob),
49
50
  alertThresholds: () => live().alertThresholds,
50
51
  webhookUrl: () => live().webhookUrl,
52
+ rememberRequest: (ref) => store.rememberRequest(ref),
53
+ recordSuccess: (ref) => store.recordSuccess(ref),
54
+ recordSwitch: (ref) => store.recordSwitch(ref),
55
+ recordExhaust: (ref) => store.recordExhaust(ref),
56
+ recordBroken: (ref) => store.recordBroken(ref),
51
57
  recordHistory,
52
58
  fetchImpl: fetch,
53
59
  fetchForRef,
@@ -57,7 +63,8 @@ export function createAdapterManager({
57
63
  const cfg = live()
58
64
  if (!cfg.cascadingFallback) throw err
59
65
  const logged = await store.loggedInProviders()
60
- const available = Object.keys(logged || {}).filter((p) => logged[p] && p !== provider)
66
+ const loggedList = Array.isArray(logged) ? logged : Object.keys(logged || {}).filter((p) => logged[p])
67
+ const available = loggedList.filter((p) => p !== provider)
61
68
  visited.add(provider)
62
69
  const nextVendor = resolveFallbackVendor(provider, available, cfg.cascadingChain, visited)
63
70
  if (!nextVendor) throw err
@@ -83,6 +90,7 @@ export function createAdapterManager({
83
90
  async function syncAdapter() {
84
91
  if (adapterDisposed) return
85
92
  const desired = await store.loggedInProviders()
93
+ if (adapterDisposed) return
86
94
  const globalProviders = ctx.llm && typeof ctx.llm.listProviders === 'function'
87
95
  ? ctx.llm.listProviders().map((p) => p.id)
88
96
  : []
@@ -95,6 +103,7 @@ export function createAdapterManager({
95
103
  logger.info(`[dsh-subscriptions] syncAdapter skipping already-bound providers: ${skipped.join(', ')}`)
96
104
  }
97
105
 
106
+ if (adapterDisposed) return
98
107
  if (!handle && claimable.length) {
99
108
  handle = ctx.llm.registerAdapter(claimable, adapter)
100
109
  registeredByUs = new Set(claimable)
@@ -239,7 +248,6 @@ export function createAdapterManager({
239
248
  })
240
249
 
241
250
  let retries = 0
242
- let retryTimer
243
251
  const scheduleRetry = () => {
244
252
  if (adapterDisposed || retries >= 30) return
245
253
  retryTimer = setTimeout(retrySync, 1000)
@@ -303,5 +311,15 @@ export function createAdapterManager({
303
311
  refreshModels,
304
312
  cascadeFallbackStream,
305
313
  registerAdapterLifecycle,
314
+ dispose: () => {
315
+ adapterDisposed = true
316
+ adapter.dispose()
317
+ if (retryTimer) clearTimeout(retryTimer)
318
+ if (handle) {
319
+ disposeAdapterHandle(handle)
320
+ handle = undefined
321
+ registeredByUs.clear()
322
+ }
323
+ },
306
324
  }
307
325
  }
package/lib/adapter.js CHANGED
@@ -32,6 +32,11 @@ export class SubscriptionAdapter extends LlmAdapter {
32
32
  constructor(deps) {
33
33
  super()
34
34
  this.deps = deps
35
+ this.disposed = false
36
+ }
37
+
38
+ dispose() {
39
+ this.disposed = true
35
40
  }
36
41
 
37
42
  providerInfo(provider) {
@@ -127,6 +132,9 @@ export class SubscriptionAdapter extends LlmAdapter {
127
132
  const t0 = Date.now()
128
133
  let firstChunkTime = null
129
134
  let outputTokens = 0
135
+ let inputTokens = 0
136
+ let cacheReadTokens = 0
137
+ let cacheWriteTokens = 0
130
138
  let approxChars = 0
131
139
  const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
132
140
  const vendor = getVendor(provider)
@@ -201,6 +209,15 @@ export class SubscriptionAdapter extends LlmAdapter {
201
209
  if (u && typeof u.outputTokens === 'number' && u.outputTokens > 0) {
202
210
  outputTokens = u.outputTokens
203
211
  }
212
+ if (u && typeof u.inputTokens === 'number') {
213
+ inputTokens = u.inputTokens
214
+ }
215
+ if (u && typeof u.cacheReadTokens === 'number') {
216
+ cacheReadTokens = u.cacheReadTokens
217
+ }
218
+ if (u && typeof u.cacheWriteTokens === 'number') {
219
+ cacheWriteTokens = u.cacheWriteTokens
220
+ }
204
221
  } else if (chunk.type === 'text-delta' && typeof chunk.text === 'string') {
205
222
  approxChars += chunk.text.length
206
223
  }
@@ -232,12 +249,20 @@ export class SubscriptionAdapter extends LlmAdapter {
232
249
  ms: totalMs,
233
250
  ttftMs,
234
251
  tps,
252
+ inputTokens: inputTokens || undefined,
235
253
  outputTokens: outputTokens || undefined,
254
+ cacheReadTokens: cacheReadTokens || undefined,
255
+ cacheWriteTokens: cacheWriteTokens || undefined,
236
256
  kind: 'stream',
237
257
  })
238
258
  } catch { /* ignore quota capture failure */ }
239
259
  }
240
260
  } catch (err) {
261
+ if (err && (err.code === 'QUOTA' || err.code === 'QUOTA_EXCEEDED' || err.status === 429)) {
262
+ if (typeof deps.recordExhaust === 'function') deps.recordExhaust(account.ref)
263
+ } else if (err && (err.status === 401 || err.status === 403 || err.code === 'TOKEN_REVOKED' || err.code === 'BROKEN')) {
264
+ if (typeof deps.recordBroken === 'function') deps.recordBroken(account.ref)
265
+ }
241
266
  if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
242
267
  await deps.saveBlob(account.ref, {
243
268
  ...blob,
@@ -275,7 +300,7 @@ export class SubscriptionAdapter extends LlmAdapter {
275
300
  }
276
301
  // #91: when the whole pool is exhausted and nothing has been streamed
277
302
  // yet, continue seamlessly on local Ollama instead of failing the chat.
278
- if (!yieldedAny && typeof deps.ollamaFallback === 'function') {
303
+ if (!yieldedAny && !options.signal?.aborted && typeof deps.ollamaFallback === 'function') {
279
304
  yield* deps.ollamaFallback({ options, provider, err })
280
305
  return
281
306
  }
package/lib/cascade.js CHANGED
@@ -9,6 +9,16 @@ export const DEFAULT_CASCADE_CHAINS = {
9
9
  copilot: ['cursor', 'codex'],
10
10
  cursor: ['copilot', 'claude'],
11
11
  antigravity: ['copilot', 'claude'],
12
+ kimi: ['qwen', 'glm', 'spark'],
13
+ glm: ['kimi', 'qwen', 'spark'],
14
+ qwen: ['kimi', 'glm', 'spark'],
15
+ spark: ['qwen', 'kimi', 'glm'],
16
+ jetbrains: ['copilot', 'cursor'],
17
+ perplexity: ['copilot', 'cursor'],
18
+ replit: ['copilot', 'cursor'],
19
+ cody: ['copilot', 'cursor'],
20
+ kiro: ['copilot', 'cursor'],
21
+ ernie: ['qwen', 'glm'],
12
22
  }
13
23
 
14
24
  /**
package/lib/images.js CHANGED
@@ -205,25 +205,48 @@ export function toOpenAiImage(norm) {
205
205
  }
206
206
 
207
207
  export function toAnthropicImage(norm) {
208
- if (!norm.base64) return null
209
- return {
210
- type: 'image',
211
- source: {
212
- type: 'base64',
213
- media_type: norm.mimeType || 'image/jpeg',
214
- data: norm.base64,
215
- },
208
+ if (!norm) return null
209
+ if (norm.base64) {
210
+ return {
211
+ type: 'image',
212
+ source: {
213
+ type: 'base64',
214
+ media_type: norm.mimeType || 'image/jpeg',
215
+ data: norm.base64,
216
+ },
217
+ }
216
218
  }
219
+ if (norm.url) {
220
+ return {
221
+ type: 'image',
222
+ source: {
223
+ type: 'url',
224
+ url: norm.url,
225
+ },
226
+ }
227
+ }
228
+ return null
217
229
  }
218
230
 
219
231
  export function toGeminiImage(norm) {
220
- if (!norm.base64) return null
221
- return {
222
- inlineData: {
223
- mimeType: norm.mimeType || 'image/jpeg',
224
- data: norm.base64,
225
- },
232
+ if (!norm) return null
233
+ if (norm.base64) {
234
+ return {
235
+ inlineData: {
236
+ mimeType: norm.mimeType || 'image/jpeg',
237
+ data: norm.base64,
238
+ },
239
+ }
226
240
  }
241
+ if (norm.url) {
242
+ return {
243
+ fileData: {
244
+ mimeType: norm.mimeType || 'image/jpeg',
245
+ fileUri: norm.url,
246
+ },
247
+ }
248
+ }
249
+ return null
227
250
  }
228
251
 
229
252
  export function toCodexImage(norm) {
package/lib/index.js CHANGED
@@ -22,6 +22,7 @@ import { HistoryStore, resolveHistoryDir } from './history.js'
22
22
  import { createDiagnosticsReport } from './diagnostics.js'
23
23
  import { createAccountsView } from './accounts-view.js'
24
24
  import { createAdapterManager } from './adapter-manager.js'
25
+ import { pickAccount } from './rotate.js'
25
26
 
26
27
  export { observeForecast, estimateForecast } from './forecast.js'
27
28
  export { formatRelativeReset } from './relative-time.js'
@@ -328,7 +329,8 @@ export function apply(ctx, rawConfig) {
328
329
  ctx.effect(() => ctx.provide('subscriptionImages', {
329
330
  async available() {
330
331
  const logged = await store.loggedInProviders()
331
- return ['codex', 'grok'].filter((name) => logged && logged[name])
332
+ const loggedList = Array.isArray(logged) ? logged : Object.keys(logged || {}).filter((name) => logged[name])
333
+ return ['codex', 'grok'].filter((name) => loggedList.includes(name))
332
334
  },
333
335
  sizes: IMAGE_SIZES,
334
336
  async generate(request) {
@@ -337,18 +339,20 @@ export function apply(ctx, rawConfig) {
337
339
  throw new Error(`unknown subscription provider: ${provider}`)
338
340
  }
339
341
  const accounts = await store.listAccounts(provider)
340
- const slot = (accounts || []).find((row) => row && row.ref)
342
+ const eligible = pickAccount(accounts || [], Date.now(), { family: provider === 'codex' ? 'gpt' : 'grok' })
343
+ const slot = eligible || (accounts || []).find((row) => row && row.ref && row.hasToken)
341
344
  if (!slot) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
342
345
  const raw = await store.resolveRaw(slot.ref)
343
346
  if (!raw) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
344
347
  const session = await store.ensureFresh(provider, parseBlob(raw), slot.ref)
348
+ const fetchFn = (typeof fetchForRef === 'function' && fetchForRef(slot.ref)) || fetch
345
349
  return generateOnce({
346
350
  provider,
347
351
  prompt: request.prompt,
348
352
  size: request.size,
349
353
  quality: request.quality,
350
354
  session,
351
- fetchImpl: fetch,
355
+ fetchImpl: fetchFn,
352
356
  signal: request.signal,
353
357
  })
354
358
  },
@@ -170,6 +170,6 @@ export async function* streamResponses(body) {
170
170
  if (translator.terminated) return
171
171
  }
172
172
  if (!translator.terminated) {
173
- yield { type: 'finish', reason: { kind: 'stop' } }
173
+ yield { type: 'finish', reason: { kind: 'error', failure: { code: 'INCOMPLETE', message: 'stream terminated before response.completed' } } }
174
174
  }
175
175
  }
package/lib/rotate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { computePacingRisk, calculateBurnRatePerHour } from "./forecast.js"
1
2
  import { isQuarantined } from './quarantine.js'
2
3
  import { getPinnedAccountRef, pinSession } from './session-pin.js'
3
4
 
@@ -92,11 +93,20 @@ export function pickAccount(accounts, nowMs, opts) {
92
93
  // Tier 1: Quota unknown (null) & healthy
93
94
  // Tier 2: Cooldown, quarantine or exhausted
94
95
  const tiers = [[], [], []]
95
- const fallback = []
96
96
 
97
97
  for (const acc of list) {
98
98
  if (!acc || !acc.hasToken) continue
99
99
 
100
+ if (!opts || opts.autoPacing !== false) {
101
+ if (acc.pacingRisk === undefined) {
102
+ const rem = acc.quota?.remainingPercent ?? (acc.usagePercent != null ? (100 - Number(acc.usagePercent)) : null)
103
+ const pace = acc.pacePerHour || (Array.isArray(acc.samples) ? calculateBurnRatePerHour(acc.samples, now) : 0)
104
+ const resetAt = acc.quota?.resetAt || 0
105
+ const riskInfo = computePacingRisk(rem, pace, resetAt, now)
106
+ acc.pacingRisk = riskInfo.pacingRisk
107
+ }
108
+ }
109
+
100
110
  // VIP Slot Reservation (#175)
101
111
  if (acc.vipOnly && !isVipRequest) continue
102
112
 
@@ -105,11 +115,15 @@ export function pickAccount(accounts, nowMs, opts) {
105
115
 
106
116
  const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
107
117
  const inQuarantine = isQuarantined(acc, now)
108
- const exhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
118
+ const isHardExhausted = (acc.usagePercent != null && Number(acc.usagePercent) >= 100 && (!acc.quota || !acc.quota.resetAt || Number(acc.quota.resetAt) > now))
119
+
120
+ if (isCooldown || inQuarantine || isHardExhausted) {
121
+ continue
122
+ }
109
123
 
110
- if (isCooldown || inQuarantine || exhausted) {
124
+ const softExhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
125
+ if (softExhausted) {
111
126
  tiers[2].push(acc)
112
- fallback.push(acc)
113
127
  continue
114
128
  }
115
129
 
@@ -140,7 +154,7 @@ export function pickAccount(accounts, nowMs, opts) {
140
154
  if (aWeight !== bWeight) return bWeight - aWeight
141
155
 
142
156
  // 3. Health Score
143
- return (b.healthScore || 100) - (a.healthScore || 100)
157
+ return (b.healthScore ?? 100) - (a.healthScore ?? 100)
144
158
  })
145
159
 
146
160
  const chosen = tier[0]
@@ -151,7 +165,6 @@ export function pickAccount(accounts, nowMs, opts) {
151
165
  }
152
166
  }
153
167
 
154
- if (fallback.length) return fallback[0]
155
168
  return null
156
169
  }
157
170
 
@@ -53,7 +53,7 @@ export function registerProxyRoutes(ctx, state) {
53
53
  const url = new URL(req.url || '/', 'http://localhost')
54
54
  const parts = url.pathname.replace(/^\/dsh-subscriptions\/proxy\//, '').split('/').filter(Boolean)
55
55
  const provider = parts[0]
56
- const restPath = '/' + parts.slice(1).join('/')
56
+ const restPath = '/' + parts.slice(1).join('/') + (url.search || '')
57
57
  if (!isProvider(provider)) {
58
58
  writeJson(res, 404, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
59
59
  return
package/lib/sse.js CHANGED
@@ -2,7 +2,10 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
2
2
  const reader = body && typeof body.getReader === 'function' ? body.getReader() : null
3
3
  const decoder = new TextDecoder()
4
4
  let buffer = ''
5
+ let terminated = false
6
+
5
7
  async function* fromText(chunk) {
8
+ if (terminated) return
6
9
  buffer += chunk
7
10
  let sep
8
11
  while ((sep = buffer.search(/\r?\n\r?\n/)) >= 0) {
@@ -10,12 +13,16 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
10
13
  buffer = buffer.slice(sep).replace(/^\r?\n\r?\n/, '')
11
14
  const dataLines = []
12
15
  for (const line of raw.split(/\r?\n/)) {
13
- if (line.startsWith(":")) continue // SSE comments / keep-alive pings
16
+ if (line.startsWith(':')) continue // SSE comments / keep-alive pings
14
17
  if (line.startsWith('data:')) dataLines.push(line.slice(5).trimStart())
15
18
  }
16
19
  if (!dataLines.length) continue
17
20
  const data = dataLines.join('\n')
18
- if (data === '[DONE]') return
21
+ if (data === '[DONE]') {
22
+ terminated = true
23
+ yield '[DONE]'
24
+ return
25
+ }
19
26
  yield data
20
27
  }
21
28
  }
@@ -36,7 +43,7 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
36
43
  reject(err)
37
44
  }, idleTimeoutMs)
38
45
  })
39
-
46
+
40
47
  let abortPromise
41
48
  let abortHandler
42
49
  if (signal) {
@@ -64,12 +71,15 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
64
71
 
65
72
  if (reader) {
66
73
  try {
67
- while (true) {
74
+ while (!terminated) {
68
75
  const { done, value } = await readWithTimeout(reader)
69
76
  if (done) break
70
77
  yield* fromText(decoder.decode(value, { stream: true }))
78
+ if (terminated) break
79
+ }
80
+ if (!terminated) {
81
+ yield* fromText(decoder.decode())
71
82
  }
72
- yield* fromText(decoder.decode())
73
83
  } finally {
74
84
  if (typeof reader.cancel === 'function') await reader.cancel().catch(() => {})
75
85
  }
@@ -77,11 +87,19 @@ export async function* iterateSse(body, { idleTimeoutMs = 60000, signal } = {})
77
87
  }
78
88
  if (body && typeof body[Symbol.asyncIterator] === 'function') {
79
89
  for await (const chunk of body) {
80
- if (signal && signal.aborted) break
90
+ if (signal && signal.aborted) {
91
+ const err = new Error('stream aborted by client')
92
+ err.name = 'AbortError'
93
+ throw err
94
+ }
95
+ if (terminated) break
81
96
  const text = typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true })
82
97
  yield* fromText(text)
98
+ if (terminated) break
99
+ }
100
+ if (!terminated) {
101
+ yield* fromText(decoder.decode())
83
102
  }
84
- yield* fromText(decoder.decode())
85
103
  return
86
104
  }
87
105
  const text = typeof body === 'string' ? body : await new Response(body).text()
@@ -1,40 +1,44 @@
1
1
  export function sanitizeToolName(name) {
2
- if (typeof name !== 'string' || !name.trim()) return 'tool_fn'
3
- const sanitized = name.trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)
4
- return sanitized || 'tool_fn'
2
+ if (typeof name !== "string" || !name.trim()) return "tool_fn"
3
+ const sanitized = name.trim().replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
4
+ return sanitized || "tool_fn"
5
5
  }
6
6
 
7
7
  export function sanitizeDescription(desc) {
8
- if (typeof desc === 'string') return desc.trim()
9
- if (desc == null) return ''
8
+ if (typeof desc === "string") return desc.trim()
9
+ if (desc == null) return ""
10
10
  return String(desc).trim()
11
11
  }
12
12
 
13
13
  export function normalizeJsonSchema(schema, depth = 0) {
14
- if (depth > 12) return { type: 'string' }
15
- if (!schema || typeof schema !== 'object') {
16
- return { type: 'object' }
14
+ if (depth > 12) return { type: "string" }
15
+ if (!schema || typeof schema !== "object") {
16
+ return { type: "object" }
17
17
  }
18
18
  if (Array.isArray(schema)) {
19
- return normalizeJsonSchema(schema[0] || { type: 'object' }, depth + 1)
19
+ return normalizeJsonSchema(schema[0] || { type: "object" }, depth + 1)
20
20
  }
21
21
 
22
22
  const out = {}
23
23
 
24
+ if (schema.title) out.title = String(schema.title)
25
+
24
26
  let type = schema.type
25
27
  let nullable = false
26
28
  if (Array.isArray(type)) {
27
- nullable = type.includes('null')
28
- type = type.find((t) => t && t !== 'null') || 'string'
29
+ nullable = type.includes("null")
30
+ type = type.find((t) => t && t !== "null") || "string"
29
31
  }
30
- if (typeof type === 'string') {
32
+ if (typeof type === "string") {
31
33
  out.type = type.toLowerCase()
32
34
  } else if (schema.properties) {
33
- out.type = 'object'
35
+ out.type = "object"
34
36
  } else if (schema.items) {
35
- out.type = 'array'
37
+ out.type = "array"
38
+ } else if (schema.anyOf || schema.oneOf || schema.allOf) {
39
+ // Schema combinator without explicit type
36
40
  } else {
37
- out.type = depth === 0 ? 'object' : 'string'
41
+ out.type = depth === 0 ? "object" : "string"
38
42
  }
39
43
 
40
44
  if (nullable) {
@@ -45,51 +49,74 @@ export function normalizeJsonSchema(schema, depth = 0) {
45
49
  out.description = String(schema.description)
46
50
  }
47
51
 
48
- if (out.type === 'object') {
49
- if (schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) {
52
+ if (schema.default !== undefined) {
53
+ out.default = schema.default
54
+ }
55
+
56
+ if (out.type === "object" || (!out.type && (schema.properties || schema.required || schema.additionalProperties !== undefined))) {
57
+ if (schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)) {
50
58
  out.properties = {}
51
59
  for (const [key, val] of Object.entries(schema.properties)) {
52
- if (typeof key === 'string' && key) {
60
+ if (typeof key === "string" && key) {
53
61
  out.properties[key] = normalizeJsonSchema(val, depth + 1)
54
62
  }
55
63
  }
56
64
  }
57
65
 
58
66
  if (Array.isArray(schema.required)) {
59
- const propKeys = new Set(Object.keys(out.properties))
67
+ const propKeys = out.properties ? new Set(Object.keys(out.properties)) : null
60
68
  const reqs = schema.required
61
- .filter((k) => typeof k === 'string' && k.length > 0)
62
- .filter((k) => propKeys.size === 0 || propKeys.has(k))
69
+ .filter((k) => typeof k === "string" && k.length > 0)
70
+ .filter((k) => !propKeys || propKeys.size === 0 || propKeys.has(k))
63
71
  if (reqs.length > 0) {
64
72
  out.required = [...new Set(reqs)]
65
73
  }
66
74
  }
67
75
 
68
- if (typeof schema.additionalProperties === 'boolean') {
76
+ if (typeof schema.additionalProperties === "boolean") {
69
77
  out.additionalProperties = schema.additionalProperties
78
+ } else if (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) {
79
+ out.additionalProperties = normalizeJsonSchema(schema.additionalProperties, depth + 1)
70
80
  }
71
- } else if (out.type === 'array') {
81
+ } else if (out.type === "array" || (!out.type && schema.items)) {
72
82
  if (schema.items) {
73
83
  out.items = normalizeJsonSchema(
74
84
  Array.isArray(schema.items) ? schema.items[0] : schema.items,
75
85
  depth + 1,
76
86
  )
77
87
  } else {
78
- out.items = { type: 'string' }
88
+ out.items = { type: "string" }
79
89
  }
80
90
  }
81
91
 
92
+ if (Array.isArray(schema.anyOf)) {
93
+ out.anyOf = schema.anyOf.map((s) => normalizeJsonSchema(s, depth + 1))
94
+ }
95
+ if (Array.isArray(schema.oneOf)) {
96
+ out.oneOf = schema.oneOf.map((s) => normalizeJsonSchema(s, depth + 1))
97
+ }
98
+ if (Array.isArray(schema.allOf)) {
99
+ out.allOf = schema.allOf.map((s) => normalizeJsonSchema(s, depth + 1))
100
+ }
101
+
82
102
  if (Array.isArray(schema.enum) && schema.enum.length > 0) {
83
103
  const cleaned = [...new Set(
84
104
  schema.enum
85
- .map((item) => (item != null ? String(item).trim() : ''))
86
- .filter((item) => item.length > 0),
105
+ .filter((v) => v !== undefined && v !== null)
106
+ .map((v) => (typeof v === "string" ? v.trim() : v))
107
+ .filter((v) => (typeof v === "string" ? v.length > 0 : true))
87
108
  )]
88
109
  if (cleaned.length > 0) {
89
110
  out.enum = cleaned
90
111
  }
91
112
  }
92
113
 
114
+ if (typeof schema.pattern === "string") out.pattern = schema.pattern
115
+ if (typeof schema.minimum === "number") out.minimum = schema.minimum
116
+ if (typeof schema.maximum === "number") out.maximum = schema.maximum
117
+ if (typeof schema.minLength === "number") out.minLength = schema.minLength
118
+ if (typeof schema.maxLength === "number") out.maxLength = schema.maxLength
119
+
93
120
  return out
94
121
  }
95
122
 
@@ -98,7 +125,7 @@ export function normalizeTools(tools) {
98
125
  const validTools = []
99
126
 
100
127
  for (const tool of tools) {
101
- if (!tool || typeof tool !== 'object') continue
128
+ if (!tool || typeof tool !== "object") continue
102
129
  const rawName = tool.name || (tool.function && tool.function.name)
103
130
  if (!rawName) continue
104
131
 
package/lib/wire.js CHANGED
@@ -162,7 +162,12 @@ export async function* openaiChatStream(body) {
162
162
  let textBlock = null
163
163
  const tools = new Map()
164
164
  let finish = { kind: 'stop' }
165
+ let sawTerminal = false
165
166
  for await (const data of iterateSse(body)) {
167
+ if (data === '[DONE]') {
168
+ sawTerminal = true
169
+ break
170
+ }
166
171
  const json = jsonSse(data)
167
172
  if (!json) continue
168
173
  const choice = (json.choices && json.choices[0]) || {}
@@ -196,9 +201,19 @@ export async function* openaiChatStream(body) {
196
201
  }
197
202
  }
198
203
  if (json.usage) yield { type: 'usage', usage: json.usage }
199
- if (choice.finish_reason === 'length') finish = { kind: 'length' }
200
- else if (choice.finish_reason === 'tool_calls') finish = { kind: 'tool' }
204
+ if (choice.finish_reason) {
205
+ sawTerminal = true
206
+ if (choice.finish_reason === 'length') finish = { kind: 'max-tokens' }
207
+ else if (choice.finish_reason === 'tool_calls' || choice.finish_reason === 'function_call') finish = { kind: 'tool-calls' }
208
+ else if (choice.finish_reason === 'content_filter') finish = { kind: 'error', failure: { code: 'CONTENT_FILTER', message: 'content filter triggered' } }
209
+ else if (choice.finish_reason === 'stop') finish = { kind: 'stop' }
210
+ }
211
+ }
212
+ if (!sawTerminal && (textBlock !== null || tools.size > 0)) {
213
+ yield { type: 'finish', reason: { kind: 'error', failure: { code: 'INCOMPLETE', message: 'stream terminated unexpectedly before finish_reason or terminal marker' } } }
214
+ return
201
215
  }
216
+ if (finish.kind === 'stop' && tools.size > 0) finish = { kind: 'tool-calls' }
202
217
  yield { type: 'finish', reason: finish }
203
218
  }
204
219
 
@@ -208,10 +223,20 @@ export async function* anthropicStream(body) {
208
223
  let index = 0
209
224
  let textIndex = 0
210
225
  const toolCallIds = new Map()
226
+ let finish = { kind: 'stop' }
227
+ let sawTerminal = false
228
+ let usageState = null
229
+ let yieldedUsage = false
211
230
  for await (const data of iterateSse(body)) {
231
+ if (data === '[DONE]') {
232
+ sawTerminal = true
233
+ break
234
+ }
212
235
  const json = jsonSse(data)
213
236
  if (!json) continue
214
- if (json.type === 'content_block_start') {
237
+ if (json.type === 'message_start' && json.message && json.message.usage) {
238
+ usageState = { ...(usageState || {}), ...json.message.usage }
239
+ } else if (json.type === 'content_block_start') {
215
240
  const block = json.content_block || {}
216
241
  if (block.type === 'text') {
217
242
  textIndex = json.index ?? index++
@@ -243,17 +268,54 @@ export async function* anthropicStream(body) {
243
268
  argumentsDelta: delta.partial_json,
244
269
  }
245
270
  }
246
- } else if (json.type === 'message_delta' && json.usage) {
247
- yield {
248
- type: 'usage',
249
- usage: {
250
- input: Number(json.usage.input_tokens ?? json.usage.input ?? 0) || 0,
251
- output: Number(json.usage.output_tokens ?? json.usage.output ?? 0) || 0,
252
- },
271
+ } else if (json.type === 'message_delta') {
272
+ sawTerminal = true
273
+ if (json.delta && json.delta.stop_reason) {
274
+ const sr = json.delta.stop_reason
275
+ if (sr === 'tool_use') finish = { kind: 'tool-calls' }
276
+ else if (sr === 'max_tokens') finish = { kind: 'max-tokens' }
277
+ else finish = { kind: 'stop' }
278
+ }
279
+ if (json.usage) {
280
+ usageState = { ...(usageState || {}), ...json.usage }
281
+ }
282
+ if (usageState) {
283
+ const norm = toTokenUsage(usageState)
284
+ yieldedUsage = true
285
+ yield {
286
+ type: 'usage',
287
+ usage: {
288
+ ...usageState,
289
+ ...(norm || {}),
290
+ input: norm ? norm.inputTokens : (Number(usageState.input_tokens ?? usageState.input ?? 0) || 0),
291
+ output: norm ? norm.outputTokens : (Number(usageState.output_tokens ?? usageState.output ?? 0) || 0),
292
+ },
293
+ }
253
294
  }
295
+ } else if (json.type === 'error') {
296
+ sawTerminal = true
297
+ const err = json.error || {}
298
+ finish = { kind: 'error', failure: { code: String(err.type || 'ANTHROPIC_ERROR'), message: String(err.message || 'anthropic error') } }
299
+ }
300
+ }
301
+ if (usageState && !yieldedUsage) {
302
+ const norm = toTokenUsage(usageState)
303
+ yield {
304
+ type: 'usage',
305
+ usage: {
306
+ ...usageState,
307
+ ...(norm || {}),
308
+ input: norm ? norm.inputTokens : (Number(usageState.input_tokens ?? usageState.input ?? 0) || 0),
309
+ output: norm ? norm.outputTokens : (Number(usageState.output_tokens ?? usageState.output ?? 0) || 0),
310
+ },
254
311
  }
255
312
  }
256
- yield { type: 'finish', reason: { kind: 'stop' } }
313
+ if (!sawTerminal && (index > 0 || textIndex > 0 || toolCallIds.size > 0)) {
314
+ yield { type: 'finish', reason: { kind: 'error', failure: { code: 'INCOMPLETE', message: 'stream terminated before message_delta' } } }
315
+ return
316
+ }
317
+ if (finish.kind === 'stop' && toolCallIds.size > 0) finish = { kind: 'tool-calls' }
318
+ yield { type: 'finish', reason: finish }
257
319
  }
258
320
 
259
321
  export async function* googleStream(body) {
@@ -261,11 +323,27 @@ export async function* googleStream(body) {
261
323
  let started = false
262
324
  let callCount = 0
263
325
  const functionCallIndexes = new Map()
326
+ let finish = { kind: 'stop' }
327
+ let sawTerminal = false
264
328
  for await (const data of iterateSse(body)) {
329
+ if (data === '[DONE]') {
330
+ sawTerminal = true
331
+ break
332
+ }
265
333
  const json = jsonSse(data)
266
334
  const wrapped = json && json.response ? json.response : json
267
335
  if (!wrapped) continue
268
336
  const cand = wrapped.candidates && wrapped.candidates[0]
337
+ if (cand && cand.finishReason) {
338
+ sawTerminal = true
339
+ const fr = cand.finishReason
340
+ if (fr === 'MAX_TOKENS') finish = { kind: 'max-tokens' }
341
+ else if (fr === 'SAFETY' || fr === 'RECITATION' || fr === 'BLOCKLIST' || fr === 'PROHIBITED_CONTENT' || fr === 'SPII') {
342
+ finish = { kind: 'error', failure: { code: fr, message: cand.finishMessage || fr } }
343
+ } else if (fr === 'STOP') {
344
+ finish = { kind: 'stop' }
345
+ }
346
+ }
269
347
  const parts = cand && cand.content && Array.isArray(cand.content.parts) ? cand.content.parts : []
270
348
  for (const part of parts) {
271
349
  if (part.text) {
@@ -299,16 +377,27 @@ export async function* googleStream(body) {
299
377
  }
300
378
  }
301
379
  if (wrapped.usageMetadata) {
380
+ const u = wrapped.usageMetadata
381
+ const norm = toTokenUsage(u)
302
382
  yield {
303
383
  type: 'usage',
304
384
  usage: {
305
- input: Number(wrapped.usageMetadata.promptTokenCount ?? 0) || 0,
306
- output: Number(wrapped.usageMetadata.candidatesTokenCount ?? 0) || 0,
385
+ ...u,
386
+ ...(norm || {}),
387
+ input: norm ? norm.inputTokens : (Number(u.promptTokenCount ?? 0) || 0),
388
+ output: norm ? norm.outputTokens : (Number(u.candidatesTokenCount ?? 0) || 0),
307
389
  },
308
390
  }
309
391
  }
310
392
  }
311
- yield { type: 'finish', reason: { kind: 'stop' } }
393
+ if (!sawTerminal && (started || callCount > 0)) {
394
+ yield { type: 'finish', reason: { kind: 'error', failure: { code: 'INCOMPLETE', message: 'stream terminated before finishReason' } } }
395
+ return
396
+ }
397
+ if (finish.kind === 'stop' && (functionCallIndexes.size > 0 || callCount > 0)) {
398
+ finish = { kind: 'tool-calls' }
399
+ }
400
+ yield { type: 'finish', reason: finish }
312
401
  }
313
402
 
314
403
  export async function formTokenRequest(url, params, fetchImpl, headers, { timeoutMs = 25000 } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.38",
3
+ "version": "0.6.40",
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",