@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/lib/client-src/00-header.js +40 -0
  3. package/lib/client-src/10-locales.js +585 -0
  4. package/lib/client-src/20-hooks.js +55 -0
  5. package/lib/client-src/30-utils.js +185 -0
  6. package/lib/client-src/40-components.js +346 -0
  7. package/lib/client-src/50-card-main.js +647 -0
  8. package/lib/client-src/51-card-pool-mutators.js +501 -0
  9. package/lib/client-src/52-card-pool-rows.js +590 -0
  10. package/lib/client-src/53-card-dialogs-and-render.js +568 -0
  11. package/lib/client-src/60-header-chip.js +137 -0
  12. package/lib/client-src/90-apply.js +69 -0
  13. package/lib/config-compat.js +18 -1
  14. package/lib/http-bridge.js +2 -0
  15. package/lib/index.js +7 -1
  16. package/lib/key-state-machine.js +267 -0
  17. package/lib/model-catalog.js +296 -0
  18. package/lib/ops-balance.js +2 -147
  19. package/lib/ops-config-io.js +2 -456
  20. package/lib/ops-keys.js +2 -567
  21. package/lib/ops-metrics.js +2 -92
  22. package/lib/ops-paths.js +2 -24
  23. package/lib/ops-quick-action.js +2 -168
  24. package/lib/ops-status.js +2 -269
  25. package/lib/ops-telemetry.js +2 -276
  26. package/lib/ops-test.js +2 -255
  27. package/lib/ops-webhook.js +2 -218
  28. package/lib/pool-selection.js +36 -13
  29. package/lib/pool-state.js +3 -0
  30. package/lib/pool.js +10 -15
  31. package/lib/quota-runway.js +222 -0
  32. package/lib/routes/balance.js +147 -0
  33. package/lib/routes/config.js +456 -0
  34. package/lib/routes/guard.js +13 -0
  35. package/lib/routes/index.js +43 -0
  36. package/lib/routes/keys.js +567 -0
  37. package/lib/routes/metrics.js +92 -0
  38. package/lib/routes/paths.js +24 -0
  39. package/lib/routes/quick-action.js +168 -0
  40. package/lib/routes/status.js +269 -0
  41. package/lib/routes/telemetry.js +276 -0
  42. package/lib/routes/testing.js +255 -0
  43. package/lib/routes/webhook.js +218 -0
  44. package/lib/routes-ops.js +2 -29
  45. package/lib/stream-metrics.js +13 -5
  46. package/lib/transports/anthropic.js +125 -0
  47. package/lib/transports/index.js +23 -0
  48. package/lib/transports/openai.js +93 -0
  49. package/lib/transports/sse.js +160 -0
  50. package/lib/transports/tokens.js +124 -0
  51. 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 — operational HTTP routes for dsh-key-rotation (#253/#312/#504/#499).
2
- // Thin orchestrator: each route family lives in its own lib/ops-*.js module.
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";
@@ -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 usedTokens = extractUsageTokens(usagePayload);
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
+ }