@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 +37 -4
- package/lib/adapter-manager.js +20 -2
- package/lib/adapter.js +26 -1
- package/lib/cascade.js +10 -0
- package/lib/images.js +37 -14
- package/lib/index.js +7 -3
- package/lib/responses-stream.js +1 -1
- package/lib/rotate.js +19 -6
- package/lib/routes/proxy.js +1 -1
- package/lib/sse.js +25 -7
- package/lib/tools-normalizer.js +54 -27
- package/lib/wire.js +103 -14
- package/package.json +1 -1
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, {
|
|
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) <=
|
|
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
|
},
|
package/lib/adapter-manager.js
CHANGED
|
@@ -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
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
355
|
+
fetchImpl: fetchFn,
|
|
352
356
|
signal: request.signal,
|
|
353
357
|
})
|
|
354
358
|
},
|
package/lib/responses-stream.js
CHANGED
|
@@ -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: '
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
package/lib/routes/proxy.js
CHANGED
|
@@ -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(
|
|
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]')
|
|
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 (
|
|
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)
|
|
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()
|
package/lib/tools-normalizer.js
CHANGED
|
@@ -1,40 +1,44 @@
|
|
|
1
1
|
export function sanitizeToolName(name) {
|
|
2
|
-
if (typeof name !==
|
|
3
|
-
const sanitized = name.trim().replace(/[^a-zA-Z0-9_-]/g,
|
|
4
|
-
return sanitized ||
|
|
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 ===
|
|
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:
|
|
15
|
-
if (!schema || typeof schema !==
|
|
16
|
-
return { type:
|
|
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:
|
|
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(
|
|
28
|
-
type = type.find((t) => t && t !==
|
|
29
|
+
nullable = type.includes("null")
|
|
30
|
+
type = type.find((t) => t && t !== "null") || "string"
|
|
29
31
|
}
|
|
30
|
-
if (typeof type ===
|
|
32
|
+
if (typeof type === "string") {
|
|
31
33
|
out.type = type.toLowerCase()
|
|
32
34
|
} else if (schema.properties) {
|
|
33
|
-
out.type =
|
|
35
|
+
out.type = "object"
|
|
34
36
|
} else if (schema.items) {
|
|
35
|
-
out.type =
|
|
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 ?
|
|
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 (
|
|
49
|
-
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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:
|
|
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
|
-
.
|
|
86
|
-
.
|
|
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 !==
|
|
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
|
|
200
|
-
|
|
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 === '
|
|
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'
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|