@goodandready/dsh-key-rotation 0.8.77 → 0.8.78
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/CHANGELOG.md +7 -0
- package/lib/client-src/00-header.js +40 -0
- package/lib/client-src/10-locales.js +585 -0
- package/lib/client-src/20-hooks.js +55 -0
- package/lib/client-src/30-utils.js +185 -0
- package/lib/client-src/40-components.js +346 -0
- package/lib/client-src/50-card-main.js +647 -0
- package/lib/client-src/51-card-pool-mutators.js +501 -0
- package/lib/client-src/52-card-pool-rows.js +590 -0
- package/lib/client-src/53-card-dialogs-and-render.js +568 -0
- package/lib/client-src/60-header-chip.js +137 -0
- package/lib/client-src/90-apply.js +69 -0
- package/lib/index.js +3 -0
- package/lib/model-catalog.js +296 -0
- package/lib/pool-selection.js +36 -13
- package/lib/quota-runway.js +222 -0
- package/lib/stream-metrics.js +13 -5
- package/lib/transports/anthropic.js +125 -0
- package/lib/transports/index.js +23 -0
- package/lib/transports/openai.js +93 -0
- package/lib/transports/sse.js +160 -0
- package/lib/transports/tokens.js +124 -0
- package/package.json +15 -10
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// lib/quota-runway.js — Predictive Quota Runway and burn rate estimation (#549).
|
|
2
|
+
// Estimates time to quota exhaustion and preemptively rotates keys before hitting HTTP 429.
|
|
3
|
+
|
|
4
|
+
export const MINUTE = 60 * 1000;
|
|
5
|
+
export const HOUR = 60 * MINUTE;
|
|
6
|
+
export const DAY = 24 * HOUR;
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_WINDOW_DURATIONS = {
|
|
9
|
+
minute: MINUTE,
|
|
10
|
+
fiveMinute: 5 * MINUTE,
|
|
11
|
+
hourly: HOUR,
|
|
12
|
+
daily: DAY,
|
|
13
|
+
monthly: 30 * DAY,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bounds percentage value to [0, 100].
|
|
18
|
+
* @param {number|unknown} value
|
|
19
|
+
* @returns {number}
|
|
20
|
+
*/
|
|
21
|
+
export function clampPercent(value) {
|
|
22
|
+
const num = Number(value);
|
|
23
|
+
return Number.isFinite(num) ? Math.max(0, Math.min(100, num)) : 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Estimates when a quota window reaches 100% at the average burn rate observed.
|
|
28
|
+
*
|
|
29
|
+
* @param {object|undefined} window
|
|
30
|
+
* @param {number} window.percentUsed Current percentage used (0..100)
|
|
31
|
+
* @param {string|number} window.resetsAt ISO timestamp or epoch ms when window resets
|
|
32
|
+
* @param {number} durationMs Window duration in milliseconds
|
|
33
|
+
* @param {number} [now] Current epoch timestamp in milliseconds
|
|
34
|
+
* @returns {{
|
|
35
|
+
* etaMs: number,
|
|
36
|
+
* resetsAtMs: number,
|
|
37
|
+
* exhaustsBeforeReset: boolean,
|
|
38
|
+
* burnRatePerSec: number
|
|
39
|
+
* } | null}
|
|
40
|
+
*/
|
|
41
|
+
export function estimateRunway(window, durationMs, now = Date.now()) {
|
|
42
|
+
if (!window || window.resetsAt == null || !durationMs || durationMs <= 0) return null;
|
|
43
|
+
|
|
44
|
+
const resetsAtMs = typeof window.resetsAt === 'number' ? window.resetsAt : Date.parse(window.resetsAt);
|
|
45
|
+
if (!Number.isFinite(resetsAtMs) || resetsAtMs <= now) return null;
|
|
46
|
+
|
|
47
|
+
const used = clampPercent(window.percentUsed);
|
|
48
|
+
const timeToReset = resetsAtMs - now;
|
|
49
|
+
|
|
50
|
+
// Already 100% or more: exhausts immediately
|
|
51
|
+
if (used >= 100) {
|
|
52
|
+
return {
|
|
53
|
+
etaMs: 0,
|
|
54
|
+
resetsAtMs,
|
|
55
|
+
exhaustsBeforeReset: true,
|
|
56
|
+
burnRatePerSec: 0,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const startedAt = resetsAtMs - durationMs;
|
|
61
|
+
const elapsed = Math.max(1, now - startedAt);
|
|
62
|
+
const ratePerMs = used / elapsed;
|
|
63
|
+
|
|
64
|
+
if (!Number.isFinite(ratePerMs) || ratePerMs <= 0) {
|
|
65
|
+
return {
|
|
66
|
+
etaMs: Number.POSITIVE_INFINITY,
|
|
67
|
+
resetsAtMs,
|
|
68
|
+
exhaustsBeforeReset: false,
|
|
69
|
+
burnRatePerSec: 0,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const remainingPercent = 100 - used;
|
|
74
|
+
const etaMs = remainingPercent / ratePerMs;
|
|
75
|
+
const exhaustsBeforeReset = etaMs < timeToReset;
|
|
76
|
+
const burnRatePerSec = ratePerMs * 1000;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
etaMs,
|
|
80
|
+
resetsAtMs,
|
|
81
|
+
exhaustsBeforeReset,
|
|
82
|
+
burnRatePerSec,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stateful Quota Runway tracker for credentials and pools.
|
|
88
|
+
*/
|
|
89
|
+
export class QuotaRunway {
|
|
90
|
+
/**
|
|
91
|
+
* @param {object} [options]
|
|
92
|
+
* @param {number} [options.slidingWindowMs]
|
|
93
|
+
*/
|
|
94
|
+
constructor(options = {}) {
|
|
95
|
+
this.slidingWindowMs = options.slidingWindowMs ?? (5 * MINUTE);
|
|
96
|
+
/** @type {Map<string, Array<{ at: number, amount: number }>>} */
|
|
97
|
+
this.samples = new Map();
|
|
98
|
+
/** @type {Map<string, { limit: number, remaining: number, resetsAtMs: number, durationMs: number }>} */
|
|
99
|
+
this.knownLimits = new Map();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Records usage tokens or requests for a key.
|
|
104
|
+
* @param {string} keyRef
|
|
105
|
+
* @param {number} amount
|
|
106
|
+
* @param {number} [now]
|
|
107
|
+
*/
|
|
108
|
+
recordUsage(keyRef, amount, now = Date.now()) {
|
|
109
|
+
if (!keyRef || !amount || amount <= 0) return;
|
|
110
|
+
let list = this.samples.get(keyRef);
|
|
111
|
+
if (!list) {
|
|
112
|
+
list = [];
|
|
113
|
+
this.samples.set(keyRef, list);
|
|
114
|
+
}
|
|
115
|
+
list.push({ at: now, amount });
|
|
116
|
+
this._prune(keyRef, now);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Updates key limits detected from response headers or provider metadata.
|
|
121
|
+
* @param {string} keyRef
|
|
122
|
+
* @param {object} limits
|
|
123
|
+
* @param {number} [limits.limit]
|
|
124
|
+
* @param {number} [limits.remaining]
|
|
125
|
+
* @param {number|string} [limits.resetsAt]
|
|
126
|
+
* @param {number} [limits.durationMs]
|
|
127
|
+
* @param {number} [now]
|
|
128
|
+
*/
|
|
129
|
+
updateLimits(keyRef, limits, now = Date.now()) {
|
|
130
|
+
if (!keyRef || !limits) return;
|
|
131
|
+
const limit = Number(limits.tpmLimit ?? limits.rpmLimit ?? limits.limit);
|
|
132
|
+
const remaining = Number(limits.remainingTokens ?? limits.remainingRequests ?? limits.remaining);
|
|
133
|
+
const resetSec = Number(limits.resetTokens ?? limits.resetRequests ?? limits.retryAfter);
|
|
134
|
+
|
|
135
|
+
let resetsAtMs = NaN;
|
|
136
|
+
if (Number.isFinite(resetSec)) {
|
|
137
|
+
resetsAtMs = now + (resetSec * 1000);
|
|
138
|
+
} else if (limits.resetsAt || limits.resetAt) {
|
|
139
|
+
const at = limits.resetsAt || limits.resetAt;
|
|
140
|
+
resetsAtMs = typeof at === 'number' ? at : Date.parse(at);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const durationMs = Number(limits.durationMs) || DEFAULT_WINDOW_DURATIONS.minute;
|
|
144
|
+
|
|
145
|
+
if (Number.isFinite(limit) && Number.isFinite(remaining) && Number.isFinite(resetsAtMs)) {
|
|
146
|
+
this.knownLimits.set(keyRef, {
|
|
147
|
+
limit,
|
|
148
|
+
remaining,
|
|
149
|
+
resetsAtMs,
|
|
150
|
+
durationMs,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Checks if key is projected to exhaust its quota before the current window resets.
|
|
157
|
+
* @param {string} keyRef
|
|
158
|
+
* @param {number} [now]
|
|
159
|
+
* @returns {boolean}
|
|
160
|
+
*/
|
|
161
|
+
isExhaustingBeforeReset(keyRef, now = Date.now()) {
|
|
162
|
+
const assessment = this.assessRunway(keyRef, now);
|
|
163
|
+
return Boolean(assessment?.exhaustsBeforeReset);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Returns complete runway assessment for a key.
|
|
168
|
+
* @param {string} keyRef
|
|
169
|
+
* @param {number} [now]
|
|
170
|
+
* @returns {{
|
|
171
|
+
* exhaustsBeforeReset: boolean,
|
|
172
|
+
* etaMs: number,
|
|
173
|
+
* burnRatePerSec: number,
|
|
174
|
+
* timeToResetMs: number
|
|
175
|
+
* } | null}
|
|
176
|
+
*/
|
|
177
|
+
assessRunway(keyRef, now = Date.now()) {
|
|
178
|
+
if (!keyRef) return null;
|
|
179
|
+
|
|
180
|
+
const limits = this.knownLimits.get(keyRef);
|
|
181
|
+
if (!limits || limits.resetsAtMs <= now) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const { limit, remaining, resetsAtMs, durationMs } = limits;
|
|
186
|
+
if (limit <= 0) return null;
|
|
187
|
+
|
|
188
|
+
const used = Math.max(0, limit - remaining);
|
|
189
|
+
const percentUsed = (used / limit) * 100;
|
|
190
|
+
|
|
191
|
+
const estimate = estimateRunway(
|
|
192
|
+
{ percentUsed, resetsAt: resetsAtMs },
|
|
193
|
+
durationMs,
|
|
194
|
+
now,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (!estimate) return null;
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
exhaustsBeforeReset: estimate.exhaustsBeforeReset,
|
|
201
|
+
etaMs: estimate.etaMs,
|
|
202
|
+
burnRatePerSec: estimate.burnRatePerSec,
|
|
203
|
+
timeToResetMs: resetsAtMs - now,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
_prune(keyRef, now) {
|
|
208
|
+
const list = this.samples.get(keyRef);
|
|
209
|
+
if (!list) return;
|
|
210
|
+
const cutoff = now - this.slidingWindowMs;
|
|
211
|
+
while (list.length > 0 && list[0].at < cutoff) {
|
|
212
|
+
list.shift();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
clear() {
|
|
217
|
+
this.samples.clear();
|
|
218
|
+
this.knownLimits.clear();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const defaultQuotaRunway = new QuotaRunway();
|
package/lib/stream-metrics.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// lib/stream-metrics.js — streaming success telemetry, usage, cost and token recording (#512).
|
|
1
|
+
// lib/stream-metrics.js — streaming success telemetry, usage, cost and token recording (#512, #546, #549).
|
|
2
2
|
// Extracted from lib/rotate.js to enforce < 600 line modularity standard.
|
|
3
3
|
|
|
4
4
|
import { recordSuccess, setSessionSticky, recordFailure, extractRateLimit, isRateLimited } from './pool.js';
|
|
@@ -7,6 +7,7 @@ import { recordRequestOutcome } from './ops-analytics.js';
|
|
|
7
7
|
import { tpmRecord } from './bucket.js';
|
|
8
8
|
import { extractUsageTokens, consumeModelTokens } from './model-quota.js';
|
|
9
9
|
import { extractProviderLimits } from './provider-limits-detector.js';
|
|
10
|
+
import { normalizeUsageTokens } from './transports/tokens.js';
|
|
10
11
|
|
|
11
12
|
export async function* preserveDispatchContext(iterable, storage, store) {
|
|
12
13
|
if (!storage || typeof storage.getStore !== 'function') {
|
|
@@ -68,11 +69,15 @@ export function recordStreamSuccessMetrics(pool, activeRef, options, chunk, deps
|
|
|
68
69
|
cascaded: Boolean(options.__isCascade),
|
|
69
70
|
}, wallNow);
|
|
70
71
|
|
|
71
|
-
// 3. TPM sliding window bucket recording
|
|
72
|
-
const
|
|
72
|
+
// 3. TPM sliding window bucket recording & token normalization (#546, #549)
|
|
73
|
+
const normUsage = normalizeUsageTokens(usagePayload);
|
|
74
|
+
const usedTokens = normUsage.totalTokens > 0 ? normUsage.totalTokens : extractUsageTokens(usagePayload);
|
|
73
75
|
if (usedTokens && usedTokens > 0) {
|
|
74
76
|
if (!pool.state.tpmWindows) pool.state.tpmWindows = new Map();
|
|
75
77
|
tpmRecord(pool.state.tpmWindows, activeRef, usedTokens, wallNow);
|
|
78
|
+
if (pool.state.quotaRunway && typeof pool.state.quotaRunway.recordUsage === 'function') {
|
|
79
|
+
pool.state.quotaRunway.recordUsage(activeRef, usedTokens, wallNow);
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
// 4. Local model quota consumption
|
|
@@ -91,12 +96,15 @@ export function recordStreamSuccessMetrics(pool, activeRef, options, chunk, deps
|
|
|
91
96
|
}
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
// 5. Provider rate limit headers detection (#503)
|
|
99
|
+
// 5. Provider rate limit headers detection & predictive runway (#503, #549)
|
|
95
100
|
if (rawHdrs) {
|
|
96
101
|
const detected = extractProviderLimits(rawHdrs);
|
|
97
102
|
if (detected) {
|
|
98
103
|
if (!pool.state.detectedLimits) pool.state.detectedLimits = new Map();
|
|
99
104
|
pool.state.detectedLimits.set(activeRef, { ...detected, at: nowMs });
|
|
105
|
+
if (pool.state.quotaRunway && typeof pool.state.quotaRunway.updateLimits === 'function') {
|
|
106
|
+
pool.state.quotaRunway.updateLimits(activeRef, detected, wallNow);
|
|
107
|
+
}
|
|
100
108
|
}
|
|
101
109
|
}
|
|
102
110
|
|
|
@@ -130,4 +138,4 @@ export function recordStreamSuccessMetrics(pool, activeRef, options, chunk, deps
|
|
|
130
138
|
if (rate && Number.isFinite(rate.remaining) && quotaStore) {
|
|
131
139
|
quotaStore.set(activeRef, { remaining: rate.remaining, limit: rate.limit, reset: rate.reset, at: nowMs });
|
|
132
140
|
}
|
|
133
|
-
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// lib/transports/anthropic.js — Anthropic Claude Messages wire protocol parsing (#546).
|
|
2
|
+
// Handles event discrimination, text/thinking deltas, and split-event usage accumulation.
|
|
3
|
+
|
|
4
|
+
import { normalizeUsageTokens } from './tokens.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses an Anthropic event payload.
|
|
8
|
+
* @param {string|object} data
|
|
9
|
+
* @returns {object|null}
|
|
10
|
+
*/
|
|
11
|
+
export function parseAnthropicEvent(data) {
|
|
12
|
+
if (!data) return null;
|
|
13
|
+
if (typeof data === 'object') return data;
|
|
14
|
+
if (typeof data !== 'string') return null;
|
|
15
|
+
|
|
16
|
+
const trimmed = data.trim();
|
|
17
|
+
if (!trimmed) return null;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(trimmed);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extracts content or reasoning delta from an Anthropic event.
|
|
28
|
+
* @param {object} event
|
|
29
|
+
* @returns {{
|
|
30
|
+
* type: string,
|
|
31
|
+
* text: string|null,
|
|
32
|
+
* thinking: string|null,
|
|
33
|
+
* partialJson: string|null,
|
|
34
|
+
* stopReason: string|null
|
|
35
|
+
* }}
|
|
36
|
+
*/
|
|
37
|
+
export function extractAnthropicDelta(event) {
|
|
38
|
+
if (!event || typeof event !== 'object') {
|
|
39
|
+
return { type: 'unknown', text: null, thinking: null, partialJson: null, stopReason: null };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const type = event.type || 'unknown';
|
|
43
|
+
|
|
44
|
+
if (type === 'content_block_delta') {
|
|
45
|
+
const delta = event.delta || {};
|
|
46
|
+
const deltaType = delta.type;
|
|
47
|
+
return {
|
|
48
|
+
type,
|
|
49
|
+
text: deltaType === 'text_delta' && typeof delta.text === 'string' ? delta.text : null,
|
|
50
|
+
thinking: deltaType === 'thinking_delta' && typeof delta.thinking === 'string' ? delta.thinking : null,
|
|
51
|
+
partialJson: deltaType === 'input_json_delta' && typeof delta.partial_json === 'string' ? delta.partial_json : null,
|
|
52
|
+
stopReason: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (type === 'message_delta') {
|
|
57
|
+
return {
|
|
58
|
+
type,
|
|
59
|
+
text: null,
|
|
60
|
+
thinking: null,
|
|
61
|
+
partialJson: null,
|
|
62
|
+
stopReason: event.delta?.stop_reason ?? null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { type, text: null, thinking: null, partialJson: null, stopReason: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Creates an accumulator for Anthropic streaming usage.
|
|
71
|
+
* Anthropic provides input tokens at `message_start` and output tokens at `message_delta`.
|
|
72
|
+
*
|
|
73
|
+
* @returns {{
|
|
74
|
+
* accumulate: (event: object) => void,
|
|
75
|
+
* getUsage: () => object,
|
|
76
|
+
* reset: () => void
|
|
77
|
+
* }}
|
|
78
|
+
*/
|
|
79
|
+
export function createAnthropicUsageAccumulator() {
|
|
80
|
+
let inputTokens = 0;
|
|
81
|
+
let outputTokens = 0;
|
|
82
|
+
let cacheReadTokens = 0;
|
|
83
|
+
let cacheWriteTokens = 0;
|
|
84
|
+
let rawStart = null;
|
|
85
|
+
let rawDelta = null;
|
|
86
|
+
|
|
87
|
+
function accumulate(event) {
|
|
88
|
+
if (!event || typeof event !== 'object') return;
|
|
89
|
+
|
|
90
|
+
if (event.type === 'message_start' && event.message?.usage) {
|
|
91
|
+
rawStart = event.message.usage;
|
|
92
|
+
const norm = normalizeUsageTokens(rawStart);
|
|
93
|
+
inputTokens = norm.inputTokens;
|
|
94
|
+
cacheReadTokens = norm.cacheReadTokens;
|
|
95
|
+
cacheWriteTokens = norm.cacheWriteTokens;
|
|
96
|
+
} else if (event.type === 'message_delta' && event.usage) {
|
|
97
|
+
rawDelta = event.usage;
|
|
98
|
+
const norm = normalizeUsageTokens(rawDelta);
|
|
99
|
+
outputTokens = norm.outputTokens;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getUsage() {
|
|
104
|
+
const total = inputTokens + outputTokens;
|
|
105
|
+
return {
|
|
106
|
+
inputTokens,
|
|
107
|
+
outputTokens,
|
|
108
|
+
cacheReadTokens,
|
|
109
|
+
cacheWriteTokens,
|
|
110
|
+
totalTokens: total,
|
|
111
|
+
raw: { start: rawStart, delta: rawDelta },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function reset() {
|
|
116
|
+
inputTokens = 0;
|
|
117
|
+
outputTokens = 0;
|
|
118
|
+
cacheReadTokens = 0;
|
|
119
|
+
cacheWriteTokens = 0;
|
|
120
|
+
rawStart = null;
|
|
121
|
+
rawDelta = null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { accumulate, getUsage, reset };
|
|
125
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// lib/transports/index.js — Unified network transports and token normalization layer (#546).
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
normalizeUsageTokens,
|
|
5
|
+
extractTotalTokens,
|
|
6
|
+
} from './tokens.js';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
createSseDecoder,
|
|
10
|
+
parseSseStream,
|
|
11
|
+
} from './sse.js';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
parseOpenAiChunk,
|
|
15
|
+
extractOpenAiDelta,
|
|
16
|
+
extractOpenAiUsage,
|
|
17
|
+
} from './openai.js';
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
parseAnthropicEvent,
|
|
21
|
+
extractAnthropicDelta,
|
|
22
|
+
createAnthropicUsageAccumulator,
|
|
23
|
+
} from './anthropic.js';
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// lib/transports/openai.js — OpenAI wire protocol parsing (#546).
|
|
2
|
+
// Handles JSON deltas, reasoning streams, tool calls, and usage payloads.
|
|
3
|
+
|
|
4
|
+
import { normalizeUsageTokens } from './tokens.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parses an OpenAI streaming JSON data chunk.
|
|
8
|
+
* @param {string|object} data
|
|
9
|
+
* @returns {object|null}
|
|
10
|
+
*/
|
|
11
|
+
export function parseOpenAiChunk(data) {
|
|
12
|
+
if (!data) return null;
|
|
13
|
+
if (typeof data === 'object') return data;
|
|
14
|
+
if (typeof data !== 'string') return null;
|
|
15
|
+
|
|
16
|
+
const trimmed = data.trim();
|
|
17
|
+
if (!trimmed || trimmed === '[DONE]') return null;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(trimmed);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extracts content delta, reasoning delta, and tool calls from an OpenAI chunk.
|
|
28
|
+
* @param {object} chunk
|
|
29
|
+
* @returns {{
|
|
30
|
+
* content: string|null,
|
|
31
|
+
* reasoningContent: string|null,
|
|
32
|
+
* toolCalls: Array<object>|null,
|
|
33
|
+
* finishReason: string|null
|
|
34
|
+
* }}
|
|
35
|
+
*/
|
|
36
|
+
export function extractOpenAiDelta(chunk) {
|
|
37
|
+
if (!chunk || typeof chunk !== 'object') {
|
|
38
|
+
return {
|
|
39
|
+
content: null,
|
|
40
|
+
reasoningContent: null,
|
|
41
|
+
toolCalls: null,
|
|
42
|
+
finishReason: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : null;
|
|
47
|
+
if (!choice) {
|
|
48
|
+
return {
|
|
49
|
+
content: null,
|
|
50
|
+
reasoningContent: null,
|
|
51
|
+
toolCalls: null,
|
|
52
|
+
finishReason: null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const delta = choice.delta ?? {};
|
|
57
|
+
const finishReason = choice.finish_reason ?? null;
|
|
58
|
+
|
|
59
|
+
const content = typeof delta.content === 'string' ? delta.content : null;
|
|
60
|
+
|
|
61
|
+
// Support reasoning fields: DeepSeek reasoning_content, OpenAI thought/reasoning
|
|
62
|
+
const reasoningContent =
|
|
63
|
+
typeof delta.reasoning_content === 'string'
|
|
64
|
+
? delta.reasoning_content
|
|
65
|
+
: typeof delta.reasoning === 'string'
|
|
66
|
+
? delta.reasoning
|
|
67
|
+
: typeof delta.thought === 'string'
|
|
68
|
+
? delta.thought
|
|
69
|
+
: null;
|
|
70
|
+
|
|
71
|
+
const toolCalls = Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0
|
|
72
|
+
? delta.tool_calls
|
|
73
|
+
: null;
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
content,
|
|
77
|
+
reasoningContent,
|
|
78
|
+
toolCalls,
|
|
79
|
+
finishReason,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extracts and normalizes usage from an OpenAI chunk (e.g. stream_options.include_usage).
|
|
85
|
+
* @param {object} chunk
|
|
86
|
+
* @returns {object|null}
|
|
87
|
+
*/
|
|
88
|
+
export function extractOpenAiUsage(chunk) {
|
|
89
|
+
if (!chunk || typeof chunk !== 'object') return null;
|
|
90
|
+
const usage = chunk.usage;
|
|
91
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
92
|
+
return normalizeUsageTokens(usage);
|
|
93
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// lib/transports/sse.js — Server-Sent Events stream decoder (#546).
|
|
2
|
+
// Zero-dependency streaming parser with buffering, multi-line data support, and AbortSignal awareness.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates a stateful SSE stream decoder.
|
|
6
|
+
* @param {object} [options]
|
|
7
|
+
* @param {(event: { event: string, data: string, id?: string, retry?: number }) => void} [options.onEvent]
|
|
8
|
+
* @param {(err: Error) => void} [options.onError]
|
|
9
|
+
* @returns {{ feed: (chunk: string | Uint8Array | Buffer) => void, flush: () => void, reset: () => void }}
|
|
10
|
+
*/
|
|
11
|
+
export function createSseDecoder(options = {}) {
|
|
12
|
+
const { onEvent, onError } = options;
|
|
13
|
+
let buffer = '';
|
|
14
|
+
let eventType = 'message';
|
|
15
|
+
let dataLines = [];
|
|
16
|
+
let eventId = undefined;
|
|
17
|
+
let retry = undefined;
|
|
18
|
+
|
|
19
|
+
function dispatchCurrent() {
|
|
20
|
+
if (dataLines.length === 0) return;
|
|
21
|
+
const dataStr = dataLines.join('\n');
|
|
22
|
+
eventType = eventType || 'message';
|
|
23
|
+
|
|
24
|
+
// Discard standard SSE end-of-stream signal
|
|
25
|
+
if (dataStr.trim() !== '[DONE]' && typeof onEvent === 'function') {
|
|
26
|
+
try {
|
|
27
|
+
onEvent({
|
|
28
|
+
event: eventType,
|
|
29
|
+
data: dataStr,
|
|
30
|
+
id: eventId,
|
|
31
|
+
retry,
|
|
32
|
+
});
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (typeof onError === 'function') onError(err);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
eventType = 'message';
|
|
39
|
+
dataLines = [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function processLine(rawLine) {
|
|
43
|
+
let line = rawLine;
|
|
44
|
+
if (line.endsWith('\r')) line = line.slice(0, -1);
|
|
45
|
+
|
|
46
|
+
// Empty line signals dispatch of current event buffer
|
|
47
|
+
if (!line) {
|
|
48
|
+
dispatchCurrent();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Comment line: ignored per SSE spec
|
|
53
|
+
if (line.startsWith(':')) return;
|
|
54
|
+
|
|
55
|
+
const colonIdx = line.indexOf(':');
|
|
56
|
+
let field = line;
|
|
57
|
+
let value = '';
|
|
58
|
+
|
|
59
|
+
if (colonIdx !== -1) {
|
|
60
|
+
field = line.slice(0, colonIdx);
|
|
61
|
+
value = line.slice(colonIdx + 1);
|
|
62
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (field === 'event') {
|
|
66
|
+
eventType = value;
|
|
67
|
+
} else if (field === 'data') {
|
|
68
|
+
dataLines.push(value);
|
|
69
|
+
} else if (field === 'id') {
|
|
70
|
+
eventId = value;
|
|
71
|
+
} else if (field === 'retry') {
|
|
72
|
+
const parsed = parseInt(value, 10);
|
|
73
|
+
if (Number.isFinite(parsed) && parsed >= 0) retry = parsed;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function feed(chunk) {
|
|
78
|
+
if (chunk == null) return;
|
|
79
|
+
const str = typeof chunk === 'string'
|
|
80
|
+
? chunk
|
|
81
|
+
: (chunk instanceof Uint8Array || Buffer.isBuffer(chunk)
|
|
82
|
+
? Buffer.from(chunk).toString('utf-8')
|
|
83
|
+
: String(chunk));
|
|
84
|
+
|
|
85
|
+
buffer += str;
|
|
86
|
+
let newlineIdx;
|
|
87
|
+
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
88
|
+
const line = buffer.slice(0, newlineIdx);
|
|
89
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
90
|
+
processLine(line);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function flush() {
|
|
95
|
+
if (buffer.length > 0) {
|
|
96
|
+
const line = buffer;
|
|
97
|
+
buffer = '';
|
|
98
|
+
processLine(line);
|
|
99
|
+
}
|
|
100
|
+
dispatchCurrent();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function reset() {
|
|
104
|
+
buffer = '';
|
|
105
|
+
eventType = 'message';
|
|
106
|
+
dataLines = [];
|
|
107
|
+
eventId = undefined;
|
|
108
|
+
retry = undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { feed, flush, reset };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Async generator that consumes an async iterable of chunks and yields parsed SSE event objects.
|
|
116
|
+
* Handles AbortSignal cleanly.
|
|
117
|
+
*
|
|
118
|
+
* @param {AsyncIterable<Uint8Array | string>} asyncIterable
|
|
119
|
+
* @param {AbortSignal} [signal]
|
|
120
|
+
* @returns {AsyncGenerator<{ event: string, data: string, id?: string, retry?: number }>}
|
|
121
|
+
*/
|
|
122
|
+
export async function* parseSseStream(asyncIterable, signal) {
|
|
123
|
+
if (!asyncIterable) return;
|
|
124
|
+
|
|
125
|
+
const queue = [];
|
|
126
|
+
let error = null;
|
|
127
|
+
|
|
128
|
+
const decoder = createSseDecoder({
|
|
129
|
+
onEvent: (evt) => queue.push(evt),
|
|
130
|
+
onError: (err) => { error = err; },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const checkAbort = () => {
|
|
134
|
+
if (signal?.aborted) {
|
|
135
|
+
const abortError = new Error(signal.reason?.message ?? 'SSE stream aborted');
|
|
136
|
+
abortError.name = 'AbortError';
|
|
137
|
+
throw abortError;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
for await (const chunk of asyncIterable) {
|
|
143
|
+
checkAbort();
|
|
144
|
+
decoder.feed(chunk);
|
|
145
|
+
if (error) throw error;
|
|
146
|
+
while (queue.length > 0) {
|
|
147
|
+
checkAbort();
|
|
148
|
+
yield queue.shift();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
decoder.flush();
|
|
152
|
+
if (error) throw error;
|
|
153
|
+
while (queue.length > 0) {
|
|
154
|
+
checkAbort();
|
|
155
|
+
yield queue.shift();
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
decoder.reset();
|
|
159
|
+
}
|
|
160
|
+
}
|