@goodandready/dsh-subscriptions 0.6.25 → 0.6.26

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.
@@ -52,7 +52,14 @@ export function toGeminiSchema(schema, depth) {
52
52
  out.items = toGeminiSchema(Array.isArray(items) ? items[0] : (items || { type: 'string' }), level + 1)
53
53
  }
54
54
  if (Array.isArray(schema.enum) && schema.enum.length) {
55
- out.enum = schema.enum.map((item) => String(item))
55
+ const cleaned = [...new Set(
56
+ schema.enum
57
+ .map((item) => String(item ?? '').trim())
58
+ .filter((item) => item.length > 0),
59
+ )]
60
+ if (cleaned.length > 0) {
61
+ out.enum = cleaned
62
+ }
56
63
  }
57
64
  return out
58
65
  }
package/lib/rotate.js CHANGED
@@ -8,11 +8,19 @@ const SWITCH_CODES = new Set([
8
8
  'LICENSE_REQUIRED',
9
9
  ])
10
10
 
11
+ export function isRegionError(err) {
12
+ if (!err || typeof err !== 'object') return false
13
+ const status = Number(err.status || err.statusCode) || 0
14
+ const msg = String(err.message || '')
15
+ return status === 400 && (/FAILED_PRECONDITION/i.test(msg) || /User location is not supported/i.test(msg))
16
+ }
17
+
11
18
  export function isSwitchableError(err) {
12
19
  if (!err || typeof err !== 'object') return false
13
20
  if (err.name === 'AbortError' || err.code === 'ABORT_ERR' || err.code === 'ERR_ABORTED' || (err.message && /aborted/i.test(err.message))) return false
14
21
  const code = err.code || (err.failure && err.failure.code)
15
22
  if (SWITCH_CODES.has(code)) return true
23
+ if (isRegionError(err)) return true
16
24
  const status = err.status || err.statusCode
17
25
  return status === 401 || status === 403 || status === 429 || status === 500 || status === 502 || status === 503
18
26
  }
@@ -1,4 +1,4 @@
1
- import { pickAccount, markCooldown, isSwitchableError, modelFamily } from './rotate.js'
1
+ import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
2
2
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
3
3
 
4
4
  export async function* streamWithRotation({
@@ -45,38 +45,52 @@ export async function* streamWithRotation({
45
45
  tried.add(account.ref || account.id)
46
46
 
47
47
  let firstChunkDelivered = false
48
- try {
49
- for await (const chunk of streamOnce(account, options)) {
50
- firstChunkDelivered = true
51
- yield chunk
52
- }
53
- return
54
- } catch (err) {
55
- lastError = err
56
- // If chunks were already yielded to the caller, never rotate mid-stream
57
- // as that would repeat or scramble generated tokens.
58
- if (firstChunkDelivered) throw err
59
- if (!isSwitchableError(err)) throw err
48
+ let regionRetries = 0
49
+
50
+ while (true) {
51
+ try {
52
+ for await (const chunk of streamOnce(account, options)) {
53
+ firstChunkDelivered = true
54
+ yield chunk
55
+ }
56
+ return
57
+ } catch (err) {
58
+ lastError = err
59
+ // If chunks were already yielded to the caller, never rotate mid-stream
60
+ // as that would repeat or scramble generated tokens.
61
+ if (firstChunkDelivered) throw err
60
62
 
61
- // Move slot to cooldown and quarantine (#172)
62
- const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
63
- account.cooldownUntil = cooled.cooldownUntil
64
- if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
63
+ // Transient region 400 error: retry same account up to 2 times before rotating (#391 / GH #9)
64
+ if (isRegionError(err) && regionRetries < 2 && (!options || !options.signal || !options.signal.aborted)) {
65
+ regionRetries++
66
+ await new Promise((r) => setTimeout(r, 400 * regionRetries))
67
+ if (options && options.signal && options.signal.aborted) throw err
68
+ continue
69
+ }
65
70
 
66
- const status = Number(err && (err.status || err.statusCode) || 0)
67
- const code = String(err && err.code || '')
68
- const reason = (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
69
- ? REASON_REVOKED
70
- : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
71
- ? REASON_HARD_LIMIT
72
- : REASON_RATE_LIMIT
71
+ if (!isSwitchableError(err)) throw err
73
72
 
74
- const quarantined = putInQuarantine(account, reason, nowMs())
75
- account.quarantineUntil = quarantined.quarantineUntil
76
- account.quarantineReason = quarantined.quarantineReason
73
+ // Move slot to cooldown and quarantine (#172)
74
+ const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
75
+ account.cooldownUntil = cooled.cooldownUntil
76
+ if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
77
77
 
78
- if (onCooldown) onCooldown(account)
79
- // Loop continues seamlessly to next slot if no chunk was delivered yet
78
+ const status = Number(err && (err.status || err.statusCode) || 0)
79
+ const code = String(err && err.code || '')
80
+ const reason = (status === 401 || code === 'AUTH' || code === 'TOKEN_REVOKED')
81
+ ? REASON_REVOKED
82
+ : (status === 403 || code === 'HARD_LIMIT' || code === 'LICENSE_REQUIRED')
83
+ ? REASON_HARD_LIMIT
84
+ : REASON_RATE_LIMIT
85
+
86
+ const quarantined = putInQuarantine(account, reason, nowMs())
87
+ account.quarantineUntil = quarantined.quarantineUntil
88
+ account.quarantineReason = quarantined.quarantineReason
89
+
90
+ if (onCooldown) onCooldown(account)
91
+ // Break inner retry loop to continue to next account in outer while (true) loop
92
+ break
93
+ }
80
94
  }
81
95
  }
82
96
  }
package/lib/usage.js CHANGED
@@ -70,37 +70,70 @@ export function windowLabel(id) {
70
70
  return known || { zh: id, en: id }
71
71
  }
72
72
 
73
+ function resolveWindowId(value, key) {
74
+ if (value && typeof value === 'object') {
75
+ const semantic = value.modelId ?? value.model ?? value.window ?? value.name ?? value.id ?? value.scope
76
+ if (typeof semantic === 'string' && semantic.trim() && !/^\d+$/.test(semantic.trim())) {
77
+ return { id: semantic.trim(), isModelBucket: Boolean(value.modelId || value.model) }
78
+ }
79
+ }
80
+ if (typeof key === 'string' && key.trim() && !/^\d+$/.test(key.trim())) {
81
+ return { id: key.trim(), isModelBucket: false }
82
+ }
83
+ return null
84
+ }
85
+
73
86
  // Walk vendor usage JSON; every node carrying a utilization-style number
74
- // becomes a named window keyed by its JSON key.
87
+ // becomes a named window keyed by its semantic identifier or JSON key.
75
88
  export function usageWindows(obj) {
76
89
  const out = []
77
90
  const seen = new Set()
78
91
  function walk(value, key, depth) {
79
- if (!value || typeof value !== "object" || depth > 6) return
80
- const pct = numberOrNull(
81
- value.utilization ?? value.used_percent ?? value.usedPercent ?? value.creditUsagePercent ?? value.used_percentage,
82
- )
83
- if (pct != null && key && !seen.has(key)) {
84
- seen.add(key)
85
- const label = windowLabel(key)
86
- out.push({ id: key, zh: label.zh, en: label.en, usedPercent: pct })
87
- }
88
- const frac = numberOrNull(value.remainingFraction)
89
- if (frac != null && key && !seen.has(key)) {
90
- seen.add(key)
91
- const label = windowLabel(key)
92
- out.push({ id: key, zh: label.zh, en: label.en, usedPercent: (1 - frac) * 100 })
93
- }
94
- const ufrac = numberOrNull(value.usedFraction)
95
- if (ufrac != null && key && !seen.has(key)) {
96
- seen.add(key)
97
- const label = windowLabel(key)
98
- out.push({ id: key, zh: label.zh, en: label.en, usedPercent: ufrac * 100 })
92
+ if (!value || typeof value !== 'object' || depth > 6) return
93
+ const resolved = resolveWindowId(value, key)
94
+ if (resolved && !seen.has(resolved.id)) {
95
+ const pct = numberOrNull(
96
+ value.utilization ?? value.used_percent ?? value.usedPercent ?? value.creditUsagePercent ?? value.used_percentage,
97
+ )
98
+ const frac = numberOrNull(value.remainingFraction)
99
+ const ufrac = numberOrNull(value.usedFraction)
100
+ let usedPercent = null
101
+ if (pct != null) usedPercent = pct
102
+ else if (frac != null) usedPercent = (1 - frac) * 100
103
+ else if (ufrac != null) usedPercent = ufrac * 100
104
+
105
+ if (usedPercent != null) {
106
+ seen.add(resolved.id)
107
+ const label = windowLabel(resolved.id)
108
+ out.push({
109
+ id: resolved.id,
110
+ zh: label.zh,
111
+ en: label.en,
112
+ usedPercent,
113
+ isModelBucket: resolved.isModelBucket,
114
+ })
115
+ }
99
116
  }
100
117
  for (const [k, child] of Object.entries(value)) {
101
- if (child && typeof child === "object") walk(child, k, depth + 1)
118
+ if (child && typeof child === 'object') walk(child, k, depth + 1)
119
+ }
120
+ }
121
+ walk(obj, '', 0)
122
+
123
+ // Separate model buckets from standard named windows
124
+ const standardWindows = out.filter((w) => !w.isModelBucket)
125
+ const modelBuckets = out.filter((w) => w.isModelBucket)
126
+
127
+ let keptModelBuckets = []
128
+ if (modelBuckets.length) {
129
+ // Only surface model buckets when usage > 0, keeping the tightest 1-2
130
+ const active = modelBuckets.filter((w) => w.usedPercent > 0)
131
+ if (active.length) {
132
+ active.sort((a, b) => b.usedPercent - a.usedPercent)
133
+ keptModelBuckets = active.slice(0, 2)
102
134
  }
103
135
  }
104
- walk(obj, "", 0)
105
- return out.length ? out : null
136
+
137
+ const result = [...standardWindows, ...keptModelBuckets].map(({ isModelBucket: _, ...rest }) => rest)
138
+ return result.length ? result : null
106
139
  }
package/lib/wire.js CHANGED
@@ -190,7 +190,7 @@ export async function* openaiChatStream(body) {
190
190
  yield {
191
191
  type: 'tool-call-delta',
192
192
  index: block.index,
193
- id: block.id,
193
+ id: String(block.id || `call_${block.index}`),
194
194
  ...(block.name ? { name: block.name } : {}),
195
195
  argumentsDelta: fragment,
196
196
  }
@@ -207,20 +207,24 @@ export { streamResponses as codexResponsesStream } from './responses-stream.js'
207
207
  export async function* anthropicStream(body) {
208
208
  let index = 0
209
209
  let textIndex = 0
210
+ const toolCallIds = new Map()
210
211
  for await (const data of iterateSse(body)) {
211
212
  const json = jsonSse(data)
212
213
  if (!json) continue
213
214
  if (json.type === 'content_block_start') {
214
215
  const block = json.content_block || {}
215
216
  if (block.type === 'text') {
216
- textIndex = json.index || index++
217
+ textIndex = json.index ?? index++
217
218
  yield { type: 'block-start', index: textIndex, blockType: 'text' }
218
219
  } else if (block.type === 'tool_use') {
219
- yield { type: 'block-start', index: json.index || index++, blockType: 'tool-call' }
220
+ const bIndex = json.index ?? index++
221
+ const toolId = String(block.id || `toolu_${bIndex}`)
222
+ toolCallIds.set(bIndex, toolId)
223
+ yield { type: 'block-start', index: bIndex, blockType: 'tool-call' }
220
224
  yield {
221
225
  type: 'tool-call-delta',
222
- index: json.index || 0,
223
- id: block.id,
226
+ index: bIndex,
227
+ id: toolId,
224
228
  name: block.name,
225
229
  argumentsDelta: '',
226
230
  }
@@ -228,16 +232,25 @@ export async function* anthropicStream(body) {
228
232
  } else if (json.type === 'content_block_delta') {
229
233
  const delta = json.delta || {}
230
234
  if (delta.type === 'text_delta' && delta.text) {
231
- yield { type: 'text-delta', index: json.index || textIndex, text: delta.text }
235
+ yield { type: 'text-delta', index: json.index ?? textIndex, text: delta.text }
232
236
  } else if (delta.type === 'input_json_delta' && delta.partial_json) {
237
+ const bIndex = json.index ?? 0
238
+ const toolId = toolCallIds.get(bIndex) || `toolu_${bIndex}`
233
239
  yield {
234
240
  type: 'tool-call-delta',
235
- index: json.index || 0,
241
+ index: bIndex,
242
+ id: toolId,
236
243
  argumentsDelta: delta.partial_json,
237
244
  }
238
245
  }
239
246
  } else if (json.type === 'message_delta' && json.usage) {
240
- yield { type: 'usage', usage: 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
+ },
253
+ }
241
254
  }
242
255
  }
243
256
  yield { type: 'finish', reason: { kind: 'stop' } }
@@ -246,6 +259,8 @@ export async function* anthropicStream(body) {
246
259
  export async function* googleStream(body) {
247
260
  let index = 0
248
261
  let started = false
262
+ let callCount = 0
263
+ const functionCallIndexes = new Map()
249
264
  for await (const data of iterateSse(body)) {
250
265
  const json = jsonSse(data)
251
266
  const wrapped = json && json.response ? json.response : json
@@ -261,10 +276,23 @@ export async function* googleStream(body) {
261
276
  yield { type: 'text-delta', index, text: part.text }
262
277
  }
263
278
  if (part.functionCall) {
264
- yield { type: 'block-start', index: index + 1, blockType: 'tool-call' }
279
+ const rawId = part.functionCall.id
280
+ let callIndex
281
+ let callId
282
+ if (rawId && functionCallIndexes.has(rawId)) {
283
+ callIndex = functionCallIndexes.get(rawId)
284
+ callId = String(rawId)
285
+ } else {
286
+ callCount++
287
+ callIndex = index + callCount
288
+ callId = String(rawId || `gemini_call_${callCount}`)
289
+ if (rawId) functionCallIndexes.set(rawId, callIndex)
290
+ yield { type: 'block-start', index: callIndex, blockType: 'tool-call' }
291
+ }
265
292
  yield {
266
293
  type: 'tool-call-delta',
267
- index: index + 1,
294
+ index: callIndex,
295
+ id: callId,
268
296
  name: part.functionCall.name,
269
297
  argumentsDelta: JSON.stringify(part.functionCall.args || {}),
270
298
  }
@@ -274,8 +302,8 @@ export async function* googleStream(body) {
274
302
  yield {
275
303
  type: 'usage',
276
304
  usage: {
277
- input: wrapped.usageMetadata.promptTokenCount,
278
- output: wrapped.usageMetadata.candidatesTokenCount,
305
+ input: Number(wrapped.usageMetadata.promptTokenCount ?? 0) || 0,
306
+ output: Number(wrapped.usageMetadata.candidatesTokenCount ?? 0) || 0,
279
307
  },
280
308
  }
281
309
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.25",
3
+ "version": "0.6.26",
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",