@goodandready/dsh-key-rotation 0.8.77 → 0.8.79
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 +14 -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/config-compat.js +18 -1
- package/lib/http-bridge.js +2 -0
- package/lib/index.js +7 -1
- package/lib/key-state-machine.js +267 -0
- package/lib/model-catalog.js +296 -0
- package/lib/ops-balance.js +2 -147
- package/lib/ops-config-io.js +2 -456
- package/lib/ops-keys.js +2 -567
- package/lib/ops-metrics.js +2 -92
- package/lib/ops-paths.js +2 -24
- package/lib/ops-quick-action.js +2 -168
- package/lib/ops-status.js +2 -269
- package/lib/ops-telemetry.js +2 -276
- package/lib/ops-test.js +2 -255
- package/lib/ops-webhook.js +2 -218
- package/lib/pool-selection.js +36 -13
- package/lib/pool-state.js +3 -0
- package/lib/pool.js +10 -15
- package/lib/quota-runway.js +222 -0
- package/lib/routes/balance.js +147 -0
- package/lib/routes/config.js +456 -0
- package/lib/routes/guard.js +13 -0
- package/lib/routes/index.js +43 -0
- package/lib/routes/keys.js +567 -0
- package/lib/routes/metrics.js +92 -0
- package/lib/routes/paths.js +24 -0
- package/lib/routes/quick-action.js +168 -0
- package/lib/routes/status.js +269 -0
- package/lib/routes/telemetry.js +276 -0
- package/lib/routes/testing.js +255 -0
- package/lib/routes/webhook.js +218 -0
- package/lib/routes-ops.js +2 -29
- 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,218 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
// lib/ops-webhook.js — webhook interactive action route (#312 split from routes-ops.js, #388 telegram secret-token & answerCallbackQuery).
|
|
3
|
+
import {
|
|
4
|
+
json,
|
|
5
|
+
readJson,
|
|
6
|
+
} from '../http-bridge.js';
|
|
7
|
+
import { bestEffort } from '../best-effort.js';
|
|
8
|
+
import { WEBHOOK_ACTION_PATH } from './paths.js';
|
|
9
|
+
import { resolveAction } from '../webhook.js';
|
|
10
|
+
|
|
11
|
+
function constantTimeEqual(a, b) {
|
|
12
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
13
|
+
const aBuf = Buffer.from(a);
|
|
14
|
+
const bBuf = Buffer.from(b);
|
|
15
|
+
return aBuf.length === bBuf.length && crypto.timingSafeEqual(aBuf, bBuf);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getHeader(headers, name) {
|
|
19
|
+
if (!headers || typeof headers !== 'object') return '';
|
|
20
|
+
const target = name.toLowerCase();
|
|
21
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
22
|
+
if (k.toLowerCase() === target) return String(v ?? '');
|
|
23
|
+
}
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {object} ctx cordis context
|
|
29
|
+
* @param {object} deps live dependencies from apply()
|
|
30
|
+
*/
|
|
31
|
+
export function registerWebhookActionRoute(ctx, deps) {
|
|
32
|
+
const {
|
|
33
|
+
buildRuntime,
|
|
34
|
+
poolState,
|
|
35
|
+
setRotationDisabled,
|
|
36
|
+
circuitBreaker,
|
|
37
|
+
} = deps;
|
|
38
|
+
|
|
39
|
+
ctx.effect(() => ctx.webServer.register({
|
|
40
|
+
kind: 'exact',
|
|
41
|
+
path: WEBHOOK_ACTION_PATH,
|
|
42
|
+
handler: async (req, res) => {
|
|
43
|
+
if (req.method !== 'POST') { json(res, 405, { error: { code: 'method', message: 'POST only' } }); return; }
|
|
44
|
+
const runtime = buildRuntime();
|
|
45
|
+
const expected = runtime.webhookActionToken;
|
|
46
|
+
if (!expected) { json(res, 503, { error: { code: 'no-token', message: 'dsh-key-rotation: webhookActionToken is not configured' } }); return; }
|
|
47
|
+
|
|
48
|
+
const authHeader = getHeader(req.headers, 'authorization');
|
|
49
|
+
const secretHeader = getHeader(req.headers, 'x-telegram-bot-api-secret-token');
|
|
50
|
+
const bearerMatch = constantTimeEqual(authHeader, `Bearer ${expected}`);
|
|
51
|
+
const secretMatch = constantTimeEqual(secretHeader, expected);
|
|
52
|
+
if (!bearerMatch && !secretMatch) {
|
|
53
|
+
json(res, 401, { error: { code: 'unauthorized', message: 'dsh-key-rotation: bad webhook action token' } });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let body;
|
|
58
|
+
try { body = await readJson(req); } catch (e) { json(res, 400, { error: { code: 'bad-request', message: String(e?.message ?? e) } }); return; }
|
|
59
|
+
|
|
60
|
+
// #222 / #388: Telegram setWebhook registration helper
|
|
61
|
+
if (typeof body?.setWebhook === 'object' && body.setWebhook) {
|
|
62
|
+
const botToken = typeof body.setWebhook.botToken === 'string' ? body.setWebhook.botToken : '';
|
|
63
|
+
if (!botToken) { json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: setWebhook.botToken required' } }); return; }
|
|
64
|
+
const TELEGRAM_BOT_TOKEN_RE = /^[0-9]{5,16}:[a-zA-Z0-9_-]{20,50}$/;
|
|
65
|
+
if (!TELEGRAM_BOT_TOKEN_RE.test(botToken)) {
|
|
66
|
+
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: invalid telegram bot token format' } });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const rawUrl = typeof body.setWebhook.url === 'string' ? body.setWebhook.url.trim() : '';
|
|
70
|
+
if (!rawUrl || !rawUrl.startsWith('https://')) {
|
|
71
|
+
json(res, 400, { error: { code: 'bad-request', message: 'dsh-key-rotation: explicit public https url required in setWebhook.url' } });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const hookRes = await fetch(`https://api.telegram.org/bot${botToken}/setWebhook`, {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'content-type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({
|
|
79
|
+
url: rawUrl,
|
|
80
|
+
secret_token: expected,
|
|
81
|
+
allowed_updates: ['callback_query'],
|
|
82
|
+
}),
|
|
83
|
+
signal: AbortSignal.timeout(10000),
|
|
84
|
+
});
|
|
85
|
+
const hookData = await hookRes.json().catch(() => ({}));
|
|
86
|
+
json(res, 200, { ok: hookRes.ok, url: rawUrl, secret_token_set: true, telegram: hookData });
|
|
87
|
+
} catch (e) {
|
|
88
|
+
json(res, 502, { error: { code: 'telegram-failed', message: String(e?.message ?? e) } });
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const callbackQueryId = typeof body?.callback_query?.id === 'string'
|
|
94
|
+
? body.callback_query.id
|
|
95
|
+
: (typeof body?.callback_query?.id === 'number' ? String(body.callback_query.id) : '');
|
|
96
|
+
const botToken = typeof body?.botToken === 'string' ? body.botToken : '';
|
|
97
|
+
|
|
98
|
+
const respond = async (statusCode, payload, answerText) => {
|
|
99
|
+
if (callbackQueryId) {
|
|
100
|
+
payload.method = 'answerCallbackQuery';
|
|
101
|
+
payload.callback_query_id = callbackQueryId;
|
|
102
|
+
payload.text = answerText || payload.error?.message || 'OK';
|
|
103
|
+
if (botToken) {
|
|
104
|
+
await bestEffort('telegram-answerCallbackQuery', () => fetch(`https://api.telegram.org/bot${botToken}/answerCallbackQuery`, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: { 'content-type': 'application/json' },
|
|
107
|
+
body: JSON.stringify({ callback_query_id: callbackQueryId, text: payload.text }),
|
|
108
|
+
signal: AbortSignal.timeout(5000),
|
|
109
|
+
}), ctx?.logger);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
json(res, statusCode, payload);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Accept callback payloads from formatInteractive (Telegram/Discord/Slack) or plain {action}
|
|
116
|
+
let action = typeof body?.action === 'string' ? body.action : '';
|
|
117
|
+
if (!action && typeof body?.data === 'string') {
|
|
118
|
+
try { action = String(JSON.parse(body.data)?.id ?? ''); } catch { action = ''; }
|
|
119
|
+
}
|
|
120
|
+
if (!action && typeof body?.callback_data === 'string') {
|
|
121
|
+
try { action = String(JSON.parse(body.callback_data)?.id ?? ''); } catch { action = ''; }
|
|
122
|
+
}
|
|
123
|
+
// #222 / #388: Telegram update envelope {update_id, callback_query:{id, data}}
|
|
124
|
+
if (!action && typeof body?.callback_query?.data === 'string') {
|
|
125
|
+
try {
|
|
126
|
+
const parsed = JSON.parse(body.callback_query.data);
|
|
127
|
+
action = typeof parsed?.id === 'string' ? parsed.id : String(parsed ?? '');
|
|
128
|
+
} catch {
|
|
129
|
+
action = body.callback_query.data;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
action = resolveAction(action);
|
|
133
|
+
if (!action) {
|
|
134
|
+
await respond(400, { error: { code: 'bad-action', message: 'dsh-key-rotation: no action in payload' } }, 'No action in payload');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const provider = action.startsWith('pause-') || action.startsWith('reset-') ? action.replace(/^(pause|reset)-/, '') : '';
|
|
138
|
+
try {
|
|
139
|
+
if (action === 'disable-rotation') {
|
|
140
|
+
setRotationDisabled(true);
|
|
141
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.('[dsh-key-rotation] rotation DISABLED via webhook action');
|
|
142
|
+
await respond(200, { ok: true, action }, 'Rotation disabled');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (action === 'enable-rotation') {
|
|
146
|
+
setRotationDisabled(false);
|
|
147
|
+
await respond(200, { ok: true, action }, 'Rotation enabled');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (action.startsWith('pause-') || action.startsWith('reset-')) {
|
|
151
|
+
const st = poolState.get(provider);
|
|
152
|
+
if (!st) {
|
|
153
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (action.startsWith('pause-')) {
|
|
157
|
+
const until = Date.now() + 3600000; // 1h pause
|
|
158
|
+
let found = false;
|
|
159
|
+
for (const [key, s] of poolState.entries()) {
|
|
160
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
161
|
+
found = true;
|
|
162
|
+
for (const ref of (s.failedUntil ? [...s.failedUntil.keys()] : [])) {
|
|
163
|
+
s.failedUntil.set(ref, Math.max(s.failedUntil.get(ref) ?? 0, until));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const p of buildRuntime().poolByRef.values()) {
|
|
168
|
+
if (p.provider === provider || p.base === provider || p.base?.startsWith(provider + '::')) {
|
|
169
|
+
found = true;
|
|
170
|
+
for (const ref of p.refs) p.state?.failedUntil?.set(ref, Math.max(p.state?.failedUntil?.get(ref) ?? 0, until));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!found) {
|
|
174
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} PAUSED 1h via webhook action`);
|
|
178
|
+
await respond(200, { ok: true, action, provider, until }, `Pool ${provider} paused 1h`);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let found = false;
|
|
182
|
+
let cleared = 0;
|
|
183
|
+
for (const [key, s] of poolState.entries()) {
|
|
184
|
+
if (key === provider || key.startsWith(provider + '::')) {
|
|
185
|
+
found = true;
|
|
186
|
+
cleared += s.failedUntil ? s.failedUntil.size : 0;
|
|
187
|
+
s.failedUntil?.clear();
|
|
188
|
+
s.failCounts?.clear();
|
|
189
|
+
s.authFailCounts?.clear();
|
|
190
|
+
s.brokenUntil?.clear();
|
|
191
|
+
s.revokedRefs?.clear();
|
|
192
|
+
s.tokenUsage?.clear();
|
|
193
|
+
s.switches = 0;
|
|
194
|
+
s.lastReason = undefined;
|
|
195
|
+
s.lastSwitchAt = undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (!found) {
|
|
199
|
+
await respond(404, { error: { code: 'not-found', message: `dsh-key-rotation: no pool for '${provider}'` } }, `Pool not found: ${provider}`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
let circuitReset = false;
|
|
203
|
+
const br = circuitBreaker ?? buildRuntime().breaker;
|
|
204
|
+
if (br) {
|
|
205
|
+
if (typeof br.reset === 'function') { br.reset(provider); circuitReset = true; }
|
|
206
|
+
else if (typeof br.onSuccess === 'function') { br.onSuccess(provider); circuitReset = true; }
|
|
207
|
+
}
|
|
208
|
+
(ctx?.logger ? ctx.logger('dsh-key-rotation') : null)?.warn?.(`[dsh-key-rotation] pool ${provider} RESET via webhook action (circuitReset=${circuitReset})`);
|
|
209
|
+
await respond(200, { ok: true, action, provider, cleared, circuitReset }, `Pool ${provider} reset`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
await respond(400, { error: { code: 'unknown-action', message: `dsh-key-rotation: unknown action '${action}'` } }, `Unknown action: ${action}`);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
await respond(500, { error: { code: 'action-failed', message: String(e?.message ?? e) } }, 'Action failed');
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}), 'dsh-key-rotation: webhook-action');
|
|
218
|
+
}
|
package/lib/routes-ops.js
CHANGED
|
@@ -1,29 +1,2 @@
|
|
|
1
|
-
// lib/routes-ops.js —
|
|
2
|
-
|
|
3
|
-
// Registration is injected with the live apply() dependencies.
|
|
4
|
-
|
|
5
|
-
import { registerStatusRoutes } from './ops-status.js';
|
|
6
|
-
import { registerTelemetryRoutes } from './ops-telemetry.js';
|
|
7
|
-
import { registerKeyRoutes } from './ops-keys.js';
|
|
8
|
-
import { registerTestRoutes } from './ops-test.js';
|
|
9
|
-
import { registerWebhookActionRoute } from './ops-webhook.js';
|
|
10
|
-
import { registerMetricsRoutes } from './ops-metrics.js';
|
|
11
|
-
import { registerBalanceRoutes } from './ops-balance.js';
|
|
12
|
-
import { registerConfigIoRoutes } from './ops-config-io.js';
|
|
13
|
-
import { registerQuickActionRoutes } from './ops-quick-action.js';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* @param {object} ctx cordis context
|
|
17
|
-
* @param {object} deps live dependencies from apply()
|
|
18
|
-
*/
|
|
19
|
-
export function registerOpsRoutes(ctx, deps) {
|
|
20
|
-
registerStatusRoutes(ctx, deps);
|
|
21
|
-
registerTelemetryRoutes(ctx, deps);
|
|
22
|
-
registerKeyRoutes(ctx, deps);
|
|
23
|
-
registerTestRoutes(ctx, deps);
|
|
24
|
-
registerWebhookActionRoute(ctx, deps);
|
|
25
|
-
registerMetricsRoutes(ctx, deps);
|
|
26
|
-
registerBalanceRoutes(ctx, deps);
|
|
27
|
-
registerConfigIoRoutes(ctx, deps);
|
|
28
|
-
registerQuickActionRoutes(ctx, deps);
|
|
29
|
-
}
|
|
1
|
+
// lib/routes-ops.js — Re-export facade for lib/routes/ (#522).
|
|
2
|
+
export * from "./routes/index.js";
|
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
|
+
}
|