@wenathlan/getry 2.0.165 → 2.0.167

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 (66) hide show
  1. package/README.md +1 -1
  2. package/accounts.ts +2087 -0
  3. package/antigravity.ts +2171 -0
  4. package/auth.ts +265 -0
  5. package/cli.ts +3 -3
  6. package/constants.ts +950 -0
  7. package/db.ts +810 -0
  8. package/docs/distributionlanes.md +1 -1
  9. package/docs/features.md +1 -2
  10. package/docs/gateway.md +2 -3
  11. package/docs/getry.md +33 -0
  12. package/engine.ts +1535 -0
  13. package/fingerprint.ts +1461 -0
  14. package/gateway.ts +1306 -0
  15. package/gatewaycheck.ts +142 -0
  16. package/intelligence.ts +169 -0
  17. package/json.ts +932 -0
  18. package/labels.ts +262 -0
  19. package/llm.ts +2709 -0
  20. package/models.ts +4017 -0
  21. package/nodeshim.ts +265 -0
  22. package/oauth.ts +2201 -0
  23. package/package.json +580 -225
  24. package/project.ts +1293 -0
  25. package/providers.ts +3238 -0
  26. package/quota.ts +1891 -0
  27. package/recovery.ts +2434 -0
  28. package/request.ts +4496 -0
  29. package/search.ts +1761 -0
  30. package/server.ts +766 -0
  31. package/streaming.ts +4398 -0
  32. package/{tests → test}/cli.test.ts +1 -1
  33. package/{tests → test}/gateway.test.ts +2 -2
  34. package/{tests → test}/gatewayversions.test.ts +1 -1
  35. package/{tests → test}/intelligence.test.ts +1 -1
  36. package/{tests → test}/keyrotation.test.ts +1 -1
  37. package/{tests → test}/onboardingstate.test.ts +1 -1
  38. package/{tests → test}/providerfixtures.ts +12 -12
  39. package/{tests → test}/researchfixtures.ts +1 -1
  40. package/{tests → test}/sandboxmodality.test.ts +1 -1
  41. package/thinking.ts +416 -0
  42. package/types.ts +809 -0
  43. package/utils.ts +263 -0
  44. package/validate.ts +61 -0
  45. package/versionregistry.ts +1325 -0
  46. /package/{tests → test}/debonairlane.test.ts +0 -0
  47. /package/{tests → test}/design.test.ts +0 -0
  48. /package/{tests → test}/family.test.ts +0 -0
  49. /package/{tests → test}/gatewayserver.ts +0 -0
  50. /package/{tests → test}/jsonpolicy.test.ts +0 -0
  51. /package/{tests → test}/llm.test.ts +0 -0
  52. /package/{tests → test}/modelroute.test.ts +0 -0
  53. /package/{tests → test}/promptlibrary.test.ts +0 -0
  54. /package/{tests → test}/providercli.test.ts +0 -0
  55. /package/{tests → test}/providerconstants.test.ts +0 -0
  56. /package/{tests → test}/providerfingerprint.test.ts +0 -0
  57. /package/{tests → test}/providermodels.test.ts +0 -0
  58. /package/{tests → test}/provideroauth.test.ts +0 -0
  59. /package/{tests → test}/routerotation.test.ts +0 -0
  60. /package/{tests → test}/serverauth.test.ts +0 -0
  61. /package/{tests → test}/serverengine.test.ts +0 -0
  62. /package/{tests → test}/serverproperty.test.ts +0 -0
  63. /package/{tests → test}/serverutils.test.ts +0 -0
  64. /package/{tests → test}/streamfallback.test.ts +0 -0
  65. /package/{tests → test}/thinkingladder.test.ts +0 -0
  66. /package/{tests → test}/torturellm.test.ts +0 -0
package/streaming.ts ADDED
@@ -0,0 +1,4398 @@
1
+ /**
2
+ * streaming — consolidated streaming engine logics of the getry family.
3
+ * @module streaming
4
+ * @family wave/logicfold2
5
+ * One keyed file per family: the former variant arms (streamfallback)
6
+ * retired into this seat with every export preserved under the same name.
7
+ */
8
+
9
+ import { BACKOFF_BASE_MS, BACKOFF_FACTOR, BACKOFF_MAX_MS, CLIENT_METADATA_RAW, CLOUDCODE_PA_BASE, GEMINI_CLI_OAUTH_CLIENT_ID, GEMINI_CLI_OAUTH_CLIENT_SECRET, GEMINI_CLI_USER_AGENT, LRU_THINKING_CACHE_SIZE, MAX_RETRIES, PROJECT_FALLBACK, RETRYABLE_STATUS_CODES } from "./constants.js";
10
+ import { ApiKeyRow } from "./db.ts";
11
+ import { X_GOOG_API_CLIENT } from "./fingerprint.js";
12
+ import { MODELS_2026, ModelId2026, UA_DEFAULT_MODEL } from "./models.js";
13
+ import { JITTER_MAX_MS, JITTER_MIN_MS } from "@wenathlan/devthink/constants";
14
+ import { fnv1a32, getJitter, thinkingCache } from "@wenathlan/devthink/core";
15
+ import { RetryConfig, delayForAttempt, parseRetryAfterMs, sleepMs } from "@wenathlan/devthink/queue";
16
+ import { createHash, randomUUID } from "node:crypto";
17
+
18
+ /* ===== block: streaming ===== */
19
+ /**
20
+ * @file streaming.ts
21
+ * @module provider/streaming
22
+ * @description
23
+ * Third-person observer view: the library watches Antigravity / CloudCode
24
+ * traffic and reproduces its streaming behavior without running Antigravity
25
+ * itself.
26
+ *
27
+ * Responsibilities isolated here:
28
+ * - Incremental SSE parser tolerant to network splits (parseSSEChunk)
29
+ * - Heterogeneous payload normalization (unwrap response/candidates/parts)
30
+ * - LRU cache for thinking signatures — 100 entries — Map-based
31
+ * - Preservation of thinking blocks between chunks (delta tracking)
32
+ * - OpenAI / Anthropic transformers compatible with opencode plugin SDK
33
+ * - Auto-recovery exponential backoff + jitter 0-80ms
34
+ * - Synthetic finish injection when cloudcode-pa closes abruptly
35
+ *
36
+ * Architectural constraints:
37
+ * - Library-first, root-first (no src/ folder)
38
+ * - Direct cloudcode-pa.googleapis.com — no localhost v1 proxy
39
+ * - Only node:* builtins + global fetch (Node >=18)
40
+ * -> node:crypto for FNV-1a, signature hashing, jitter
41
+ * -> global TextDecoder / ReadableStream (web standards in Node)
42
+ * - Production-ready: never throws in parser, defensive JSON tolerant,
43
+ * JSDoc everywhere, explicit error handling, no placeholder
44
+ *
45
+ * Date governance: 25/08/2026 — Canto do Buriti, PI, BR
46
+ * Version: 2.0.0 — the merged provider lineage
47
+ *
48
+ * Related modules:
49
+ * - constants.ts — endpoints, LRU_THINKING_CACHE_SIZE, models 2026
50
+ * - fingerprint.ts — FNV-1a reused here for deterministic ids
51
+ * - request.ts — builds Antigravity request body
52
+ * - recovery.ts — higher level auto-recovery using this module
53
+ *
54
+ * @author devthink
55
+ * @license MIT
56
+ */
57
+
58
+
59
+ import * as os from "node:os";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Frozen-owner imports — identical values owned by constants.ts / core.ts
63
+ // ---------------------------------------------------------------------------
64
+
65
+
66
+ /* the generic leaf pair of the os utility module stayed with core.ts in
67
+ * devthink/constants.ts — the gateway imports the jitter window through the
68
+ * published specifier, the provider families above resolve locally. */
69
+
70
+
71
+
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Constants — frozen, deterministic
75
+ // ---------------------------------------------------------------------------
76
+
77
+ // Values below are re-exported from the frozen owners so each symbol keeps a
78
+ // single definition; the module surface is unchanged for consumers.
79
+ export {
80
+ BACKOFF_BASE_MS,
81
+ BACKOFF_FACTOR,
82
+ BACKOFF_MAX_MS,
83
+ GEMINI_CLI_OAUTH_CLIENT_ID,
84
+ GEMINI_CLI_OAUTH_CLIENT_SECRET,
85
+ JITTER_MAX_MS,
86
+ JITTER_MIN_MS,
87
+ LRU_THINKING_CACHE_SIZE,
88
+ MAX_RETRIES,
89
+ PROJECT_FALLBACK,
90
+ fnv1a32,
91
+ getJitter,
92
+ thinkingCache,
93
+ };
94
+
95
+ // local variant: diverges from constants (owner list is [403,404,408,429,500,502,503,504]; this streaming subset omits 403/404/408)
96
+ /** Retryable HTTP status codes for cloudcode-pa */
97
+
98
+ /** Sentinel that Gemini / Antigravity sends to close stream */
99
+ export const SSE_DONE_SENTINEL = "[DONE]" as const;
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Helpers — FNV-1a 32-bit (deterministic, zero external dep)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ // fnv1a32 is imported from core.js (identical charCodeAt + Math.imul algorithm)
106
+ // and re-exported above.
107
+
108
+ /**
109
+ * SHA-256 hex truncated to 16 chars — stable key for thinking text
110
+ * when length > few hundred chars. Falls back to FNV-1a if crypto fails.
111
+ *
112
+ * @param text - thinking text to hash
113
+ */
114
+ export function hashThinkingText(text: string): string {
115
+ try {
116
+ return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 32);
117
+ } catch {
118
+ return fnv1a32(text).toString(16).padStart(8, "0");
119
+ }
120
+ }
121
+
122
+ // getJitter is imported from constants.js (identical randomInt 0-80 body)
123
+ // and re-exported above.
124
+
125
+ /**
126
+ * Promise-based sleep with jitter included for backoff
127
+ * @param ms - base delay
128
+ */
129
+ export function sleepWithJitter(ms: number): Promise<void> {
130
+ const jitter = getJitter();
131
+ const total = Math.max(0, ms + jitter);
132
+ return new Promise((resolve) => setTimeout(resolve, total));
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // LRU Cache — thinking signatures 100 entradas
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /**
140
+ * @description
141
+ * The observer notes that Claude thinking blocks carry `thoughtSignature`
142
+ * that must be preserved across turns, otherwise API returns
143
+ * "invalid signature". Original plugin stored 100 entries in Map.
144
+ * LRU eviction keeps most recent.
145
+ *
146
+ * This implementation is library-first: no external LRU dep,
147
+ * uses Map insertion order (ES2015 guarantees order).
148
+ */
149
+ // local variant: diverges from recovery (recovery.ReplaySignatureCache is the entry-based cache with signature keys and provenance; this is the hash->signature string map)
150
+ export class ReplaySignatureCache {
151
+ private readonly maxSize: number;
152
+ private readonly map: Map<string, string>;
153
+
154
+ /**
155
+ * @param maxSize - max entries, default 100 per spec
156
+ */
157
+ constructor(maxSize: number = LRU_THINKING_CACHE_SIZE) {
158
+ if (!Number.isInteger(maxSize) || maxSize <= 0) {
159
+ throw new TypeError(`maxSize must be positive integer, got ${maxSize}`);
160
+ }
161
+ this.maxSize = maxSize;
162
+ this.map = new Map<string, string>();
163
+ }
164
+
165
+ /** Current size */
166
+ get size(): number {
167
+ return this.map.size;
168
+ }
169
+
170
+ /** Max configured */
171
+ get capacity(): number {
172
+ return this.maxSize;
173
+ }
174
+
175
+ /**
176
+ * Retrieves signature for given thinking text.
177
+ * The cache key is hash of normalized text (trimmed).
178
+ *
179
+ * @param thinkingText - full thinking block text
180
+ * @returns signature or undefined
181
+ */
182
+ get(thinkingText: string): string | undefined {
183
+ if (!thinkingText || typeof thinkingText !== "string") return undefined;
184
+ const key = hashThinkingText(thinkingText.trim());
185
+ const val = this.map.get(key);
186
+ if (val !== undefined) {
187
+ // LRU touch: re-insert to move to end (most recent)
188
+ this.map.delete(key);
189
+ this.map.set(key, val);
190
+ }
191
+ return val;
192
+ }
193
+
194
+ /**
195
+ * Retrieves signature by direct hash key (when caller already hashed)
196
+ */
197
+ getByHash(hashKey: string): string | undefined {
198
+ const val = this.map.get(hashKey);
199
+ if (val !== undefined) {
200
+ this.map.delete(hashKey);
201
+ this.map.set(hashKey, val);
202
+ }
203
+ return val;
204
+ }
205
+
206
+ /**
207
+ * Stores signature keyed by thinking text
208
+ * @param thinkingText - full text
209
+ * @param signature - opaque signature string from API
210
+ */
211
+ set(thinkingText: string, signature: string): void {
212
+ if (!thinkingText || typeof thinkingText !== "string") return;
213
+ if (!signature || typeof signature !== "string") return;
214
+ const key = hashThinkingText(thinkingText.trim());
215
+ if (this.map.has(key)) this.map.delete(key);
216
+ this.map.set(key, signature);
217
+ this.evictIfNeeded();
218
+ }
219
+
220
+ /**
221
+ * Direct hash->signature set (when text not available)
222
+ */
223
+ setByHash(hashKey: string, signature: string): void {
224
+ if (!hashKey || !signature) return;
225
+ if (this.map.has(hashKey)) this.map.delete(hashKey);
226
+ this.map.set(hashKey, signature);
227
+ this.evictIfNeeded();
228
+ }
229
+
230
+ /** Checks presence without touching LRU order */
231
+ has(thinkingText: string): boolean {
232
+ if (!thinkingText) return false;
233
+ const key = hashThinkingText(thinkingText.trim());
234
+ return this.map.has(key);
235
+ }
236
+
237
+ /** Deletes entry by thinking text */
238
+ delete(thinkingText: string): boolean {
239
+ if (!thinkingText) return false;
240
+ const key = hashThinkingText(thinkingText.trim());
241
+ return this.map.delete(key);
242
+ }
243
+
244
+ /** Clears all */
245
+ clear(): void {
246
+ this.map.clear();
247
+ }
248
+
249
+ /** Iterates entries LRU order (oldest first) */
250
+ entries(): IterableIterator<[string, string]> {
251
+ return this.map.entries();
252
+ }
253
+
254
+ /** Keys in LRU order */
255
+ keys(): IterableIterator<string> {
256
+ return this.map.keys();
257
+ }
258
+
259
+ /** Returns diagnostic snapshot oldest->newest */
260
+ snapshot(): Array<{ hash: string; signaturePreview: string }> {
261
+ return Array.from(this.map.entries()).map(([h, s]) => ({
262
+ hash: h,
263
+ signaturePreview: s.slice(0, 24) + (s.length > 24 ? "…" : ""),
264
+ }));
265
+ }
266
+
267
+ private evictIfNeeded(): void {
268
+ while (this.map.size > this.maxSize) {
269
+ const oldestKey = this.map.keys().next().value;
270
+ if (oldestKey === undefined) break;
271
+ this.map.delete(oldestKey);
272
+ }
273
+ }
274
+ }
275
+
276
+ /** Global singleton used by transformers — library-first convenience */
277
+ export const globalThinkingSignatureCache = new ReplaySignatureCache(LRU_THINKING_CACHE_SIZE);
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // SSE Incremental Parser — tolerante splits
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * Raw SSE event parsed from wire.
285
+ * Observer keeps it minimal — only what cloudcode-pa uses.
286
+ */
287
+ export interface ParsedSSEEvent {
288
+ /** Event type if server sent `event: xxx` */
289
+ event?: string | undefined;
290
+ /** Optional id `id: xxx` */
291
+ id?: string | undefined;
292
+ /** Retry interval `retry: xxx` */
293
+ retry?: number | undefined;
294
+ /** Joined data payload (may be JSON string, may be [DONE]) */
295
+ data: string;
296
+ /** Raw block for debugging */
297
+ rawBlock: string;
298
+ }
299
+
300
+ /**
301
+ * Mutable buffer holding incomplete SSE tail between network chunks.
302
+ * Third-person: the observer accumulates until double newline terminator.
303
+ */
304
+ export interface SSEParserState {
305
+ /** Leftover incomplete tail */
306
+ buffer: string;
307
+ /** How many events parsed so far — useful for diagnostics */
308
+ eventsSeen: number;
309
+ }
310
+
311
+ /**
312
+ * Creates fresh parser state.
313
+ */
314
+ export function createSSEParserState(): SSEParserState {
315
+ return { buffer: "", eventsSeen: 0 };
316
+ }
317
+
318
+ /**
319
+ * Parses one text chunk incrementally, tolerant to splits.
320
+ *
321
+ * The observer learned from production: fetch streaming may split in middle
322
+ * of `data: {` JSON, or split the `\n\n` delimiter across chunks.
323
+ * Strategy:
324
+ * 1. Append chunk to state.buffer
325
+ * 2. Normalize \r\n -> \n, \r -> \n
326
+ * 3. While buffer contains `\n\n` extract block
327
+ * 4. For each block, extract data: lines (join with \n per SSE spec)
328
+ * 5. Leave incomplete tail in buffer
329
+ *
330
+ * @param chunk - new text arrived from decoder (may be partial)
331
+ * @param state - mutable buffer object — will be updated in place
332
+ * @returns parsed events from this chunk only
333
+ *
334
+ * @example
335
+ * const state = createSSEParserState();
336
+ * let ev = parseSSEChunk("data: {\"cand", state); // [] — buffered
337
+ * ev = parseSSEChunk("idates\":[]}\n\n", state); // 1 event
338
+ */
339
+ // local variant: diverges from core (stateful incremental parser with buffer/event/id/retry fields; core.parseSSEChunk is stateless and returns only joined data payloads)
340
+ export function parseSSEChunk(chunk: string, state: SSEParserState): ParsedSSEEvent[] {
341
+ if (typeof chunk !== "string") {
342
+ throw new TypeError(`parseSSEChunk chunk must be string, got ${typeof chunk}`);
343
+ }
344
+ if (!state || typeof state.buffer !== "string") {
345
+ throw new TypeError("parseSSEChunk state must have .buffer string");
346
+ }
347
+
348
+ // Defensive: if chunk empty, nothing to do
349
+ if (chunk.length === 0) return [];
350
+
351
+ // Append and normalize line endings eagerly — \r\n and \r both become \n
352
+ // This keeps split tolerance for \r\n across chunks (e.g. \r in previous, \n in current)
353
+ state.buffer += chunk;
354
+ // We cannot replace globally before we have full delimiter? We do incremental replace
355
+ // but keep logic simple: replace \r\n first then \r
356
+ // Important: don't destroy partial \r at end — if buffer ends with \r, keep it and wait for \n
357
+ // So we only replace \r\n that are complete and standalone \r not at end.
358
+ // Implementation: temporary replace \r\n -> \n, then replace remaining \r \n? Let's be safe.
359
+ let buf = state.buffer.replace(/\r\n/g, "\n");
360
+ // If last char is \r (could be split \r\n), keep it pending — but we already replaced \r\n, so lonely \r stays
361
+ // Replace lonely \r not followed by anything? We convert them to \n except when it's last char and previous chunk may have split?
362
+ // For simplicity we convert all remaining \r to \n — worst case we may prematurely split, but still valid SSE allows \r as line terminator.
363
+ buf = buf.replace(/\r/g, "\n");
364
+ state.buffer = buf;
365
+
366
+ const events: ParsedSSEEvent[] = [];
367
+ let searchStart = 0;
368
+
369
+ while (true) {
370
+ const delimiterIdx = state.buffer.indexOf("\n\n", searchStart);
371
+ if (delimiterIdx === -1) break; // incomplete block — wait for more chunks
372
+
373
+ const block = state.buffer.slice(searchStart, delimiterIdx);
374
+ // Move window past delimiter
375
+ const nextStart = delimiterIdx + 2;
376
+ // Parse block lines
377
+ const lines = block.split("\n");
378
+ let dataLines: string[] = [];
379
+ let eventType: string | undefined;
380
+ let id: string | undefined;
381
+ let retry: number | undefined;
382
+
383
+ for (const rawLine of lines) {
384
+ if (rawLine.length === 0) continue;
385
+ if (rawLine.startsWith(":")) {
386
+ // SSE comment / keepalive — ignore per spec
387
+ continue;
388
+ }
389
+ // According to SSE spec, field is up to first colon
390
+ const colonIdx = rawLine.indexOf(":");
391
+ let field: string;
392
+ let value: string;
393
+ if (colonIdx === -1) {
394
+ field = rawLine.trim();
395
+ value = "";
396
+ } else {
397
+ field = rawLine.slice(0, colonIdx).trim();
398
+ value = rawLine.slice(colonIdx + 1);
399
+ // If value starts with single space, strip it (spec)
400
+ if (value.startsWith(" ")) value = value.slice(1);
401
+ }
402
+
403
+ switch (field) {
404
+ case "data":
405
+ dataLines.push(value);
406
+ break;
407
+ case "event":
408
+ eventType = value;
409
+ break;
410
+ case "id":
411
+ id = value;
412
+ break;
413
+ case "retry":
414
+ {
415
+ const n = Number.parseInt(value, 10);
416
+ if (Number.isFinite(n)) retry = n;
417
+ }
418
+ break;
419
+ default:
420
+ // Unknown field — spec says ignore
421
+ break;
422
+ }
423
+ }
424
+
425
+ // Per spec, if dataLines empty, no event dispatch
426
+ if (dataLines.length > 0) {
427
+ const data = dataLines.join("\n"); // multiple data: lines concatenated with \n
428
+ events.push({
429
+ event: eventType,
430
+ id,
431
+ retry,
432
+ data,
433
+ rawBlock: block,
434
+ });
435
+ state.eventsSeen++;
436
+ }
437
+
438
+ // Slice buffer: remove processed portion including delimiter
439
+ // Since we search from searchStart, we need to handle correctly
440
+ // Simplify: we maintain state.buffer as remaining after nextStart
441
+ state.buffer = state.buffer.slice(nextStart);
442
+ searchStart = 0; // reset because buffer changed
443
+
444
+ // Safety: avoid infinite loop on huge buffers without delimiter
445
+ if (state.buffer.length > 10 * 1024 * 1024) {
446
+ // 10MB without delimiter is malformed — truncate to avoid OOM
447
+ // Observer logs warning but continues
448
+ console.warn("[streaming] SSE buffer exceeded 10MB without delimiter, truncating");
449
+ state.buffer = state.buffer.slice(-1024 * 1024); // keep last 1MB
450
+ break;
451
+ }
452
+ }
453
+
454
+ return events;
455
+ }
456
+
457
+ /**
458
+ * Parses a Uint8Array / Buffer chunk using TextDecoder streaming.
459
+ * Convenience wrapper around parseSSEChunk for binary fetch streams.
460
+ *
461
+ * @param binaryChunk - Uint8Array from reader.read()
462
+ * @param decoder - TextDecoder with {stream:true}
463
+ * @param state - parser state
464
+ */
465
+ export function parseSSEBinaryChunk(
466
+ binaryChunk: Uint8Array,
467
+ decoder: { decode(input: Uint8Array, options?: { stream?: boolean }): string },
468
+ state: SSEParserState,
469
+ ): ParsedSSEEvent[] {
470
+ if (!(binaryChunk instanceof Uint8Array)) {
471
+ throw new TypeError("binaryChunk must be Uint8Array");
472
+ }
473
+ if (!(decoder instanceof TextDecoder)) {
474
+ throw new TypeError("decoder must be TextDecoder");
475
+ }
476
+ const text = decoder.decode(binaryChunk, { stream: true });
477
+ return parseSSEChunk(text, state);
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Payload normalization — heterogeneous shapes
482
+ // ---------------------------------------------------------------------------
483
+
484
+ /**
485
+ * Normalized tool call extracted from Antigravity part
486
+ */
487
+ export interface NormalizedToolCall {
488
+ /** Deterministic id if not provided by model */
489
+ id: string;
490
+ /** Function / tool name sanitized */
491
+ name: string;
492
+ /** Parsed args object if JSON parsable, otherwise raw string */
493
+ args: unknown;
494
+ /** Raw JSON string of args — for OpenAI delta streaming */
495
+ argsJson: string;
496
+ /** Whether this is delta (partial args) or final */
497
+ isDelta?: boolean;
498
+ }
499
+
500
+ /**
501
+ * Single normalized chunk produced from one or more raw payloads
502
+ */
503
+ export interface NormalizedChunk {
504
+ /** Text delta to emit (incremental already deduped) */
505
+ text?: string | undefined;
506
+ /** Full accumulated text for tracking */
507
+ fullText?: string | undefined;
508
+ /** Thinking delta */
509
+ thinking?: string | undefined;
510
+ /** Full accumulated thinking */
511
+ fullThinking?: string | undefined;
512
+ /** Signature for thinking block if present */
513
+ thinkingSignature?: string | undefined;
514
+ /** Tool calls extracted */
515
+ toolCalls?: NormalizedToolCall[] | undefined;
516
+ /** Finish reason from cloudcode-pa, normalized to lowercase (stop, max_tokens, safety) */
517
+ finishReason?: string | undefined;
518
+ /** Raw original payload for debugging / grounding */
519
+ raw: unknown;
520
+ /** Candidate index if payload contained multiple candidates */
521
+ candidateIndex?: number | undefined;
522
+ /** Whether this part was marked as thought */
523
+ isThought?: boolean | undefined;
524
+ /** Grounding metadata e.g. groundingMetadata, groundingChunks */
525
+ groundingMetadata?: unknown | undefined;
526
+ /** Safety ratings if present */
527
+ safetyRatings?: unknown | undefined;
528
+ /** Usage metadata if present */
529
+ usageMetadata?: unknown | undefined;
530
+ /** Indicates stream is done (from [DONE] or finishReason) */
531
+ isFinished?: boolean | undefined;
532
+ }
533
+
534
+ /**
535
+ * Safe JSON parse — never throws, returns null on failure
536
+ * @param text - raw data string
537
+ */
538
+ export function safeJsonParse(text: string): unknown | null {
539
+ if (!text || typeof text !== "string") return null;
540
+ const trimmed = text.trim();
541
+ if (trimmed.length === 0) return null;
542
+ if (trimmed === SSE_DONE_SENTINEL) return { __sentinelDone: true };
543
+ try {
544
+ return JSON.parse(trimmed);
545
+ } catch {
546
+ // Tolerant attempt: sometimes cloudcode-pa sends multiple JSON concatenated with newline
547
+ // Try first line
548
+ try {
549
+ const firstLine = trimmed.split("\n")[0];
550
+ if (firstLine && firstLine !== trimmed) return JSON.parse(firstLine);
551
+ } catch {
552
+ // ignore
553
+ }
554
+ return null;
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Unwraps heterogeneous envelope into candidate array.
560
+ * Handles observed shapes:
561
+ * - {response:{candidates:[...]}}
562
+ * - {candidates:[...]}
563
+ * - {candidate:{...}}
564
+ * - [{...}, {...}] (already array)
565
+ * - {content:{parts:[...]}} single candidate
566
+ * - {parts:[...]} direct parts
567
+ *
568
+ * @param payload - parsed JSON object
569
+ * @returns array of candidate-like objects
570
+ */
571
+ export function unwrapCandidates(payload: unknown): unknown[] {
572
+ if (!payload || typeof payload !== "object") return [];
573
+
574
+ // Sentinel DONE already handled elsewhere
575
+ const obj = payload as any;
576
+
577
+ if (obj.__sentinelDone) return [];
578
+
579
+ // Array directly = list of candidates or payloads
580
+ if (Array.isArray(obj)) {
581
+ return obj;
582
+ }
583
+
584
+ // response.candidates
585
+ if (obj.response) {
586
+ if (Array.isArray(obj.response.candidates)) return obj.response.candidates;
587
+ if (obj.response.candidate) return [obj.response.candidate];
588
+ if (obj.response.content || obj.response.parts) return [obj.response];
589
+ }
590
+
591
+ // candidates
592
+ if (Array.isArray(obj.candidates)) return obj.candidates;
593
+
594
+ // single candidate field
595
+ if (obj.candidate && typeof obj.candidate === "object") return [obj.candidate];
596
+
597
+ // content or parts at top level considered single candidate
598
+ if (obj.content || obj.parts || obj.text || obj.thoughtSignature) return [obj];
599
+
600
+ // Fallback: treat whole object as candidate
601
+ return [obj];
602
+ }
603
+
604
+ /**
605
+ * Extracts parts array from candidate
606
+ */
607
+ export function extractParts(candidate: unknown): unknown[] {
608
+ if (!candidate || typeof candidate !== "object") return [];
609
+ const c = candidate as any;
610
+
611
+ // Direct parts
612
+ if (Array.isArray(c.parts)) return c.parts;
613
+ // content.parts
614
+ if (c.content && Array.isArray(c.content.parts)) return c.content.parts;
615
+ // content array?
616
+ if (Array.isArray(c.content)) return c.content;
617
+ // text at candidate level as single part
618
+ if (typeof c.text === "string") {
619
+ return [{ text: c.text, thought: c.thought, thoughtSignature: c.thoughtSignature }];
620
+ }
621
+
622
+ // Gemini sometimes: candidate.content.parts empty but candidate itself is part
623
+ if (c.functionCall || c.function_call) return [c];
624
+
625
+ return [];
626
+ }
627
+
628
+ /**
629
+ * Normalizes single part object into text/thinking/toolCall
630
+ * @param part - raw part
631
+ */
632
+ export function normalizePart(part: unknown): {
633
+ text?: string;
634
+ thinking?: string;
635
+ thinkingSignature?: string;
636
+ toolCall?: NormalizedToolCall;
637
+ isThought?: boolean;
638
+ } {
639
+ if (!part || typeof part !== "object") {
640
+ if (typeof part === "string") return { text: part };
641
+ return {};
642
+ }
643
+ const p = part as any;
644
+
645
+ // Text + thought marker
646
+ // Antigravity observed: {text:"xxx", thought:true, thoughtSignature:"..."}
647
+ if (typeof p.text === "string") {
648
+ if (p.thought === true || p.isThought === true || p.thoughtSignature || p.type === "thinking") {
649
+ return {
650
+ thinking: p.text,
651
+ thinkingSignature: p.thoughtSignature || p.signature,
652
+ isThought: true,
653
+ };
654
+ }
655
+ return { text: p.text, isThought: false };
656
+ }
657
+
658
+ // Thinking explicitly: {thinking:"...", signature:"..."}
659
+ if (typeof p.thinking === "string") {
660
+ return {
661
+ thinking: p.thinking,
662
+ thinkingSignature: p.thoughtSignature || p.signature,
663
+ isThought: true,
664
+ };
665
+ }
666
+
667
+ // Function call shapes
668
+ // Gemini: {functionCall:{name, args}}
669
+ // Anthropic style: {tool_use:{id,name,input}}
670
+ // OpenAI style inside Gemini: {function_call:{name,arguments}}
671
+ const fc = p.functionCall || p.function_call || p.tool_use || p.toolUse;
672
+ if (fc && typeof fc === "object") {
673
+ const nameRaw = fc.name || fc.functionName || p.name || "unknown_tool";
674
+ // Sanitize name: must start with letter or underscore for gemini, but we normalize
675
+ const name = String(nameRaw).replace(/[^a-zA-Z0-9_.-]/g, "_");
676
+ let argsRaw = fc.args || fc.arguments || fc.input || {};
677
+ let argsJson: string;
678
+ let argsParsed: unknown = argsRaw;
679
+
680
+ if (typeof argsRaw === "string") {
681
+ argsJson = argsRaw;
682
+ try {
683
+ argsParsed = JSON.parse(argsRaw);
684
+ } catch {
685
+ argsParsed = argsRaw;
686
+ }
687
+ } else {
688
+ try {
689
+ argsJson = JSON.stringify(argsRaw);
690
+ } catch {
691
+ argsJson = "{}";
692
+ }
693
+ }
694
+
695
+ const id = fc.id || p.id || `toolu_${fnv1a32(name + argsJson).toString(16)}`;
696
+
697
+ return {
698
+ toolCall: {
699
+ id: String(id),
700
+ name,
701
+ args: argsParsed,
702
+ argsJson,
703
+ isDelta: !!p.isDelta,
704
+ },
705
+ };
706
+ }
707
+
708
+ // Direct tool call at part level
709
+ if (p.name && (p.args !== undefined || p.arguments !== undefined || p.input !== undefined)) {
710
+ return {
711
+ toolCall: {
712
+ id: p.id || `toolu_${fnv1a32(p.name).toString(16)}`,
713
+ name: String(p.name),
714
+ args: p.args ?? p.arguments ?? p.input ?? {},
715
+ argsJson:
716
+ typeof (p.args ?? p.arguments ?? p.input) === "string"
717
+ ? (p.args ?? p.arguments ?? p.input)
718
+ : JSON.stringify(p.args ?? p.arguments ?? p.input ?? {}),
719
+ },
720
+ };
721
+ }
722
+
723
+ return {};
724
+ }
725
+
726
+ /**
727
+ * Maps Gemini finishReason to normalized lowercase reason
728
+ */
729
+ export function mapFinishReason(rawReason: unknown): string | undefined {
730
+ if (!rawReason || typeof rawReason !== "string") return undefined;
731
+ const r = rawReason.trim().toUpperCase();
732
+ switch (r) {
733
+ case "STOP":
734
+ case "END_TURN":
735
+ case "FINISH":
736
+ return "stop";
737
+ case "MAX_TOKENS":
738
+ case "LENGTH":
739
+ return "length";
740
+ case "SAFETY":
741
+ case "RECITATION":
742
+ return "content_filter";
743
+ case "TOOL_CALLS":
744
+ case "TOOL_USE":
745
+ return "tool_calls";
746
+ default:
747
+ return rawReason.toLowerCase();
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Core normalizer — converts raw SSE data strings (JSON) into normalized chunks.
753
+ * Tolerant to heterogeneous shapes observed in prod, daily, sandbox.
754
+ *
755
+ * @param rawDataStrings - array of data: payloads (already stripped of `data:` prefix)
756
+ * @returns normalized chunks (may be more than input if multiple candidates)
757
+ */
758
+ // local variant: diverges from core (accepts an array of raw data strings and returns rich NormalizedChunk objects; core.normalizePayloads takes one string and returns any[])
759
+ export function normalizePayloads(rawDataStrings: (string | unknown)[]): NormalizedChunk[] {
760
+ const out: NormalizedChunk[] = [];
761
+
762
+ for (const rawData of rawDataStrings) {
763
+ let parsed: unknown;
764
+
765
+ if (typeof rawData === "string") {
766
+ const trimmed = rawData.trim();
767
+ if (trimmed === "" || trimmed === SSE_DONE_SENTINEL) {
768
+ out.push({
769
+ raw: trimmed,
770
+ isFinished: true,
771
+ finishReason: "stop",
772
+ });
773
+ continue;
774
+ }
775
+ parsed = safeJsonParse(trimmed);
776
+ if (parsed === null) {
777
+ // If not JSON, treat as plain text delta (tolerant)
778
+ out.push({
779
+ text: rawData,
780
+ fullText: rawData,
781
+ raw: rawData,
782
+ });
783
+ continue;
784
+ }
785
+ } else {
786
+ parsed = rawData;
787
+ }
788
+
789
+ if (parsed === null) continue;
790
+
791
+ // Handle sentinel object
792
+ if ((parsed as any).__sentinelDone) {
793
+ out.push({
794
+ raw: parsed,
795
+ isFinished: true,
796
+ finishReason: "stop",
797
+ });
798
+ continue;
799
+ }
800
+
801
+ const candidates = unwrapCandidates(parsed);
802
+
803
+ for (let ci = 0; ci < candidates.length; ci++) {
804
+ const cand = candidates[ci] as any;
805
+ if (!cand) continue;
806
+
807
+ const parts = extractParts(cand);
808
+ let textAcc = "";
809
+ let thinkingAcc = "";
810
+ let thinkingSig: string | undefined;
811
+ const toolCalls: NormalizedToolCall[] = [];
812
+ let finishReason: string | undefined;
813
+ let groundingMetadata: unknown;
814
+ let safetyRatings: unknown;
815
+ let usageMetadata: unknown;
816
+
817
+ // Extract top-level fields from candidate
818
+ if (cand.finishReason || cand.finish_reason || cand.stopReason) {
819
+ finishReason = mapFinishReason(cand.finishReason || cand.finish_reason || cand.stopReason);
820
+ }
821
+ if (cand.groundingMetadata) groundingMetadata = cand.groundingMetadata;
822
+ if (cand.grounding_metadata) groundingMetadata = cand.grounding_metadata;
823
+ if (cand.safetyRatings) safetyRatings = cand.safetyRatings;
824
+ if (cand.usageMetadata || cand.usage_metadata) usageMetadata = cand.usageMetadata || cand.usage_metadata;
825
+
826
+ // If no parts, but candidate itself might be direct text (fallback)
827
+ if (parts.length === 0) {
828
+ // candidate may have content as string
829
+ if (typeof cand.content === "string") {
830
+ textAcc += cand.content;
831
+ } else if (typeof cand.text === "string" && !cand.thought) {
832
+ textAcc += cand.text;
833
+ } else if (typeof cand.thinking === "string") {
834
+ thinkingAcc += cand.thinking;
835
+ thinkingSig = cand.thoughtSignature || cand.signature;
836
+ }
837
+ } else {
838
+ for (const part of parts) {
839
+ const norm = normalizePart(part);
840
+ if (norm.text) textAcc += norm.text;
841
+ if (norm.thinking) thinkingAcc += norm.thinking;
842
+ if (norm.thinkingSignature) thinkingSig = norm.thinkingSignature;
843
+ if (norm.toolCall) toolCalls.push(norm.toolCall);
844
+ }
845
+ }
846
+
847
+ // Also check candidate-level thoughtSignature if no part signature
848
+ if (!thinkingSig && cand.thoughtSignature) thinkingSig = cand.thoughtSignature;
849
+ if (!thinkingSig && cand.signature) thinkingSig = cand.signature;
850
+
851
+ // Skip empty candidate unless it has finishReason
852
+ if (!textAcc && !thinkingAcc && toolCalls.length === 0 && !finishReason && !groundingMetadata) {
853
+ // Might still be useful if it contains usage etc, but skip if truly empty
854
+ // Keep as raw-only if it has any interesting field
855
+ if (!cand.candidates && !cand.content && !cand.parts) continue;
856
+ }
857
+
858
+ out.push({
859
+ text: textAcc || undefined,
860
+ fullText: textAcc || undefined,
861
+ thinking: thinkingAcc || undefined,
862
+ fullThinking: thinkingAcc || undefined,
863
+ thinkingSignature: thinkingSig,
864
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
865
+ finishReason,
866
+ groundingMetadata,
867
+ safetyRatings,
868
+ usageMetadata,
869
+ raw: cand,
870
+ candidateIndex: ci,
871
+ isThought: thinkingAcc ? true : undefined,
872
+ isFinished: finishReason ? true : undefined,
873
+ });
874
+ }
875
+ }
876
+
877
+ return out;
878
+ }
879
+
880
+ // ---------------------------------------------------------------------------
881
+ // Preservation of thinking blocks between chunks — delta tracking
882
+ // ---------------------------------------------------------------------------
883
+
884
+ /**
885
+ * Extracts delta from full text given previously sent buffer.
886
+ * If new text starts with old, returns suffix, else returns full new.
887
+ * This handles both cumulative and incremental server behavior.
888
+ *
889
+ * @param full - current accumulated text from server (may be cumulative)
890
+ * @param alreadySent - buffer already emitted to client
891
+ * @returns delta to emit now
892
+ */
893
+ export function extractDelta(full: string, alreadySent: string): string {
894
+ if (!full) return "";
895
+ if (!alreadySent) return full;
896
+ if (full === alreadySent) return "";
897
+ if (full.startsWith(alreadySent)) {
898
+ return full.slice(alreadySent.length);
899
+ }
900
+ // If server sends incremental delta that doesn't start with previous (e.g. new block),
901
+ // we check if alreadySent ends with prefix of full? simplest fallback: return full
902
+ // But try to find largest common prefix
903
+ // For production safety, just return full if not prefix — deduplication layer above handles
904
+ // However attempt small heuristic: if full length < alreadySent length, likely reset -> return full
905
+ return full;
906
+ }
907
+
908
+ /**
909
+ * Tracks streaming session to preserve thinking and text between chunks.
910
+ * The observer watches two buffers: sentThinkingBuffer and sentTextBuffer.
911
+ * Prevents duplicated thinking blocks in UI (bug #120 in original repo).
912
+ */
913
+ export class StreamingSessionTracker {
914
+ private fullThinking = "";
915
+ private sentThinking = "";
916
+ private fullText = "";
917
+ private sentText = "";
918
+ private thinkingSignature?: string | undefined;
919
+ private finished = false;
920
+ private lastFinishReason?: string | undefined;
921
+ private toolCallsSeen = new Map<string, NormalizedToolCall>();
922
+
923
+ /**
924
+ * @param signatureCache - optional LRU cache for thinking signatures
925
+ */
926
+ constructor(public readonly signatureCache: ReplaySignatureCache = globalThinkingSignatureCache) {}
927
+
928
+ /** Whether stream already finished */
929
+ get isFinished(): boolean {
930
+ return this.finished;
931
+ }
932
+
933
+ get currentThinking(): string {
934
+ return this.fullThinking;
935
+ }
936
+
937
+ get currentText(): string {
938
+ return this.fullText;
939
+ }
940
+
941
+ /**
942
+ * Pushes new normalized chunk, deduplicates thinking/text, updates signature cache,
943
+ * and returns delta-only chunk ready for transformer.
944
+ *
945
+ * @param chunk - normalized from normalizePayloads
946
+ * @returns delta chunk (with only new portions) + metadata
947
+ */
948
+ push(chunk: NormalizedChunk): NormalizedChunk {
949
+ if (!chunk) return chunk;
950
+
951
+ let textDelta: string | undefined;
952
+ let thinkingDelta: string | undefined;
953
+
954
+ // ---- thinking preservation ----
955
+ if (chunk.fullThinking || chunk.thinking) {
956
+ const incomingThinking = chunk.fullThinking ?? chunk.thinking ?? "";
957
+ // Accumulate if server sent cumulative? Heuristic: if incoming longer than current and starts with current, replace full with incoming
958
+ if (!this.fullThinking) {
959
+ this.fullThinking = incomingThinking;
960
+ } else {
961
+ // If incoming contains current as prefix, it is cumulative update
962
+ if (incomingThinking.startsWith(this.fullThinking)) {
963
+ this.fullThinking = incomingThinking;
964
+ } else if (this.fullThinking.startsWith(incomingThinking)) {
965
+ // incoming is subset already processed — keep existing full
966
+ } else {
967
+ // Different block or incremental — append if not duplicate
968
+ // Check if already contains incoming suffix
969
+ if (!this.fullThinking.includes(incomingThinking)) {
970
+ this.fullThinking += incomingThinking;
971
+ }
972
+ }
973
+ }
974
+
975
+ // Extract what still needs to be sent
976
+ thinkingDelta = extractDelta(this.fullThinking, this.sentThinking);
977
+ if (thinkingDelta) {
978
+ this.sentThinking += thinkingDelta;
979
+ }
980
+
981
+ // Signature handling — preserve latest
982
+ if (chunk.thinkingSignature) {
983
+ this.thinkingSignature = chunk.thinkingSignature;
984
+ // Cache it keyed by current full thinking (when block finalized)
985
+ // But we cache progressively as well to avoid losing signature on crash
986
+ try {
987
+ this.signatureCache.set(this.fullThinking, chunk.thinkingSignature);
988
+ } catch {
989
+ // ignore cache errors
990
+ }
991
+ } else {
992
+ // Try to recover signature from cache if this thinking text was seen before
993
+ // (multiturn continuation)
994
+ try {
995
+ const cachedSig = this.signatureCache.get(this.fullThinking);
996
+ if (cachedSig) {
997
+ this.thinkingSignature = cachedSig;
998
+ chunk.thinkingSignature = cachedSig;
999
+ }
1000
+ } catch {
1001
+ // ignore
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ // ---- text preservation ----
1007
+ if (chunk.fullText || chunk.text) {
1008
+ const incomingText = chunk.fullText ?? chunk.text ?? "";
1009
+ if (!this.fullText) {
1010
+ this.fullText = incomingText;
1011
+ } else {
1012
+ if (incomingText.startsWith(this.fullText)) {
1013
+ this.fullText = incomingText;
1014
+ } else if (!this.fullText.includes(incomingText)) {
1015
+ // Append only if not already included
1016
+ this.fullText += incomingText;
1017
+ }
1018
+ }
1019
+ textDelta = extractDelta(this.fullText, this.sentText);
1020
+ if (textDelta) this.sentText += textDelta;
1021
+ }
1022
+
1023
+ // ---- tool calls ----
1024
+ let mergedToolCalls: NormalizedToolCall[] | undefined;
1025
+ if (chunk.toolCalls && chunk.toolCalls.length > 0) {
1026
+ mergedToolCalls = [];
1027
+ for (const tc of chunk.toolCalls) {
1028
+ const prev = this.toolCallsSeen.get(tc.id);
1029
+ if (!prev) {
1030
+ this.toolCallsSeen.set(tc.id, tc);
1031
+ mergedToolCalls.push(tc);
1032
+ } else {
1033
+ // Merge args (delta)
1034
+ if (tc.argsJson && tc.argsJson !== prev.argsJson) {
1035
+ // append delta args if looks like incremental JSON
1036
+ const mergedArgsJson = prev.argsJson + tc.argsJson;
1037
+ // Keep merged but also provide delta
1038
+ const merged: NormalizedToolCall = {
1039
+ ...tc,
1040
+ argsJson: mergedArgsJson,
1041
+ isDelta: true,
1042
+ };
1043
+ this.toolCallsSeen.set(tc.id, { ...prev, argsJson: mergedArgsJson });
1044
+ mergedToolCalls.push(merged);
1045
+ }
1046
+ }
1047
+ }
1048
+ if (mergedToolCalls.length === 0) mergedToolCalls = undefined;
1049
+ }
1050
+
1051
+ // ---- finish tracking ----
1052
+ if (chunk.finishReason || chunk.isFinished) {
1053
+ this.finished = true;
1054
+ this.lastFinishReason = chunk.finishReason || "stop";
1055
+ }
1056
+
1057
+ return {
1058
+ ...chunk,
1059
+ text: textDelta ?? undefined,
1060
+ fullText: this.fullText || undefined,
1061
+ thinking: thinkingDelta ?? undefined,
1062
+ fullThinking: this.fullThinking || undefined,
1063
+ thinkingSignature: this.thinkingSignature || chunk.thinkingSignature,
1064
+ toolCalls: mergedToolCalls,
1065
+ };
1066
+ }
1067
+
1068
+ /**
1069
+ * Returns synthetic finish chunk if stream ended without explicit finish.
1070
+ * Preserves last known signature.
1071
+ */
1072
+ createSyntheticFinish(reason = "stop"): NormalizedChunk | null {
1073
+ if (this.finished) return null;
1074
+ this.finished = true;
1075
+ this.lastFinishReason = reason;
1076
+ return {
1077
+ raw: { synthetic: true, reason, fullThinking: this.fullThinking, fullText: this.fullText },
1078
+ finishReason: reason,
1079
+ isFinished: true,
1080
+ thinkingSignature: this.thinkingSignature,
1081
+ fullThinking: this.fullThinking || undefined,
1082
+ fullText: this.fullText || undefined,
1083
+ };
1084
+ }
1085
+
1086
+ /** Resets tracker for new stream */
1087
+ reset(): void {
1088
+ this.fullThinking = "";
1089
+ this.sentThinking = "";
1090
+ this.fullText = "";
1091
+ this.sentText = "";
1092
+ this.thinkingSignature = undefined;
1093
+ this.finished = false;
1094
+ this.lastFinishReason = undefined;
1095
+ this.toolCallsSeen.clear();
1096
+ }
1097
+
1098
+ /** Diagnostic snapshot */
1099
+ snapshot(): {
1100
+ fullThinkingLen: number;
1101
+ sentThinkingLen: number;
1102
+ fullTextLen: number;
1103
+ sentTextLen: number;
1104
+ finished: boolean;
1105
+ toolCalls: number;
1106
+ hasSignature: boolean;
1107
+ } {
1108
+ return {
1109
+ fullThinkingLen: this.fullThinking.length,
1110
+ sentThinkingLen: this.sentThinking.length,
1111
+ fullTextLen: this.fullText.length,
1112
+ sentTextLen: this.sentText.length,
1113
+ finished: this.finished,
1114
+ toolCalls: this.toolCallsSeen.size,
1115
+ hasSignature: !!this.thinkingSignature,
1116
+ };
1117
+ }
1118
+ }
1119
+
1120
+ // ---------------------------------------------------------------------------
1121
+ // OpenAI compatible transformer — opencode expects chat.completion.chunk
1122
+ // ---------------------------------------------------------------------------
1123
+
1124
+ export interface OpenAIChunkOptions {
1125
+ /** Model id to echo back — e.g. antigravity-gemini-3-flash */
1126
+ model: string;
1127
+ /** Optional fixed id for stream — if not provided random-ish */
1128
+ id?: string;
1129
+ /** Creation timestamp in seconds */
1130
+ created?: number;
1131
+ }
1132
+
1133
+ function generateChatId(): string {
1134
+ try {
1135
+ const bytes = createHash("sha256")
1136
+ .update(String(Date.now()) + Math.random())
1137
+ .digest("hex")
1138
+ .slice(0, 12);
1139
+ return `chatcmpl-${bytes}`;
1140
+ } catch {
1141
+ return `chatcmpl-${fnv1a32(String(Date.now())).toString(16)}`;
1142
+ }
1143
+ }
1144
+
1145
+ /**
1146
+ * Maps normalized finishReason to OpenAI finish_reason values
1147
+ */
1148
+ function mapToOpenAIFinishReason(reason?: string): string | null {
1149
+ if (!reason) return null;
1150
+ switch (reason) {
1151
+ case "stop":
1152
+ return "stop";
1153
+ case "length":
1154
+ return "length";
1155
+ case "content_filter":
1156
+ return "content_filter";
1157
+ case "tool_calls":
1158
+ return "tool_calls";
1159
+ case "tool_use":
1160
+ return "tool_calls";
1161
+ default:
1162
+ return reason;
1163
+ }
1164
+ }
1165
+
1166
+ /**
1167
+ * Transforms normalized chunks into OpenAI SSE lines.
1168
+ * Library-first: stateless transform + session tracker for delta.
1169
+ *
1170
+ * The observer emits reasoning_content for thinking (opencode supports both
1171
+ * reasoning and reasoning_content per 2026 spec).
1172
+ *
1173
+ * @param chunk - delta chunk from StreamingSessionTracker
1174
+ * @param opts - model/id/created
1175
+ * @returns array of SSE formatted strings `data: {...}\n\n`
1176
+ */
1177
+ // local variant: diverges from core (returns SSE-formatted `data: {...}` strings with OpenAIChunkOptions; core.transformToOpenAI returns a bare chunk object)
1178
+ export function transformToOpenAI(
1179
+ chunk: NormalizedChunk,
1180
+ opts: OpenAIChunkOptions,
1181
+ tracker?: StreamingSessionTracker,
1182
+ ): string[] {
1183
+ const id = opts.id ?? generateChatId();
1184
+ const created = opts.created ?? Math.floor(Date.now() / 1000);
1185
+ const choiceIndex = chunk.candidateIndex ?? 0;
1186
+
1187
+ const delta: any = {};
1188
+
1189
+ // Thinking — map to reasoning_content (opencode + OpenAI compat)
1190
+ if (chunk.thinking && chunk.thinking.length > 0) {
1191
+ delta.reasoning_content = chunk.thinking;
1192
+ delta.reasoning = chunk.thinking; // alt field for compatibility
1193
+ // Include signature if present — some clients use it for multi-turn
1194
+ if (chunk.thinkingSignature) {
1195
+ delta.reasoning_content_signature = chunk.thinkingSignature;
1196
+ delta.thinking_signature = chunk.thinkingSignature;
1197
+ }
1198
+ }
1199
+
1200
+ // Text
1201
+ if (chunk.text && chunk.text.length > 0) {
1202
+ delta.content = chunk.text;
1203
+ }
1204
+
1205
+ // Tool calls
1206
+ if (chunk.toolCalls && chunk.toolCalls.length > 0) {
1207
+ delta.tool_calls = chunk.toolCalls.map((tc, idx) => ({
1208
+ index: idx,
1209
+ id: tc.id,
1210
+ type: "function",
1211
+ function: {
1212
+ name: tc.name,
1213
+ // OpenAI streams incremental args as delta; we send full or delta depending on isDelta
1214
+ arguments: tc.argsJson,
1215
+ },
1216
+ }));
1217
+ }
1218
+
1219
+ // Grounding metadata passthrough as extra field for search models
1220
+ if (chunk.groundingMetadata) {
1221
+ delta.grounding_metadata = chunk.groundingMetadata;
1222
+ }
1223
+
1224
+ const hasDelta = Object.keys(delta).length > 0;
1225
+ const finish = mapToOpenAIFinishReason(chunk.finishReason);
1226
+
1227
+ // If nothing to emit and no finish, skip (empty chunk)
1228
+ if (!hasDelta && !finish && !chunk.isFinished) {
1229
+ return [];
1230
+ }
1231
+
1232
+ const payload: any = {
1233
+ id,
1234
+ object: "chat.completion.chunk",
1235
+ created,
1236
+ model: opts.model,
1237
+ choices: [
1238
+ {
1239
+ index: choiceIndex,
1240
+ delta: hasDelta ? delta : {},
1241
+ finish_reason: finish ?? null,
1242
+ // Include logprobs null per OpenAI spec
1243
+ logprobs: null,
1244
+ },
1245
+ ],
1246
+ };
1247
+
1248
+ // If usage present, include for final chunk (some clients expect)
1249
+ if (chunk.usageMetadata && finish) {
1250
+ payload.usage = chunk.usageMetadata;
1251
+ }
1252
+
1253
+ return [`data: ${JSON.stringify(payload)}\n\n`];
1254
+ }
1255
+
1256
+ /**
1257
+ * Factory that returns stateful OpenAI transformer with internal tracker
1258
+ * and LRU signature cache. Suitable for opencode plugin hook.
1259
+ *
1260
+ * @param opts - model options
1261
+ * @param cache - optional cache injection
1262
+ */
1263
+ export function createOpenAIStreamingTransformer(
1264
+ opts: OpenAIChunkOptions,
1265
+ cache: ReplaySignatureCache = globalThinkingSignatureCache,
1266
+ ): {
1267
+ tracker: StreamingSessionTracker;
1268
+ transform: (chunk: NormalizedChunk) => string[];
1269
+ final: () => string[];
1270
+ } {
1271
+ const tracker = new StreamingSessionTracker(cache);
1272
+ const fixedId = opts.id ?? generateChatId();
1273
+ const created = opts.created ?? Math.floor(Date.now() / 1000);
1274
+
1275
+ return {
1276
+ tracker,
1277
+ transform: (chunk: NormalizedChunk) => {
1278
+ const deduped = tracker.push(chunk);
1279
+ // If after dedup nothing new but finish, still emit finish
1280
+ if (!deduped.text && !deduped.thinking && !deduped.toolCalls && !deduped.finishReason) {
1281
+ if (deduped.isFinished || deduped.finishReason) {
1282
+ // force emit finish even if deduped empty
1283
+ return transformToOpenAI(deduped, { ...opts, id: fixedId, created }, tracker);
1284
+ }
1285
+ return [];
1286
+ }
1287
+ return transformToOpenAI(deduped, { ...opts, id: fixedId, created }, tracker);
1288
+ },
1289
+ final: () => {
1290
+ const synthetic = tracker.createSyntheticFinish("stop");
1291
+ if (!synthetic) return [];
1292
+ return transformToOpenAI(synthetic, { ...opts, id: fixedId, created }, tracker);
1293
+ },
1294
+ };
1295
+ }
1296
+
1297
+ // ---------------------------------------------------------------------------
1298
+ // Anthropic compatible transformer — opencode uses Anthropic messages SSE
1299
+ // ---------------------------------------------------------------------------
1300
+
1301
+ export interface AnthropicChunkOptions {
1302
+ /** Model to echo */
1303
+ model: string;
1304
+ /** Message id */
1305
+ messageId?: string;
1306
+ }
1307
+
1308
+ type AnthropicBlockType = "text" | "thinking" | "tool_use";
1309
+
1310
+ /**
1311
+ * Internal state for Anthropic streaming sequence.
1312
+ * Observer maintains index counters per content block.
1313
+ */
1314
+ class AnthropicStateMachine {
1315
+ public messageStarted = false;
1316
+ public nextBlockIndex = 0;
1317
+ public activeBlocks = new Map<number, AnthropicBlockType>();
1318
+ public blockThinkingSent = new Map<number, string>();
1319
+ public blockTextSent = new Map<number, string>();
1320
+ public toolJsonSent = new Map<string, string>();
1321
+ public finished = false;
1322
+
1323
+ reset(): void {
1324
+ this.messageStarted = false;
1325
+ this.nextBlockIndex = 0;
1326
+ this.activeBlocks.clear();
1327
+ this.blockThinkingSent.clear();
1328
+ this.blockTextSent.clear();
1329
+ this.toolJsonSent.clear();
1330
+ this.finished = false;
1331
+ }
1332
+ }
1333
+
1334
+ /**
1335
+ * Transforms normalized chunk into Anthropic SSE events.
1336
+ * Emits event: / data: pairs as per Anthropic Messages streaming spec:
1337
+ * message_start, content_block_start, content_block_delta, content_block_stop,
1338
+ * message_delta, message_stop
1339
+ *
1340
+ * Library-first: each call returns array of formatted SSE lines.
1341
+ *
1342
+ * @param chunk - delta chunk from tracker
1343
+ * @param opts - model/message options
1344
+ * @param state - anthropic state machine (mutable)
1345
+ * @returns array of SSE lines (`event: xxx\ndata: {...}\n\n`)
1346
+ */
1347
+ // local variant: diverges from core (full stateful Anthropic event machine with block tracking; core.transformToAnthropic maps one payload to a single delta event)
1348
+ export function transformToAnthropic(
1349
+ chunk: NormalizedChunk,
1350
+ opts: AnthropicChunkOptions,
1351
+ state: AnthropicStateMachine,
1352
+ ): string[] {
1353
+ const out: string[] = [];
1354
+ const messageId = opts.messageId ?? `msg_${fnv1a32(String(Date.now())).toString(16)}`;
1355
+
1356
+ // Helper to emit event+data pair
1357
+ const emit = (eventType: string, dataObj: unknown): void => {
1358
+ out.push(`event: ${eventType}\ndata: ${JSON.stringify(dataObj)}\n\n`);
1359
+ };
1360
+
1361
+ // message_start on first emission
1362
+ if (!state.messageStarted) {
1363
+ state.messageStarted = true;
1364
+ emit("message_start", {
1365
+ type: "message_start",
1366
+ message: {
1367
+ id: messageId,
1368
+ type: "message",
1369
+ role: "assistant",
1370
+ content: [],
1371
+ model: opts.model,
1372
+ stop_reason: null,
1373
+ stop_sequence: null,
1374
+ usage: { input_tokens: 0, output_tokens: 0 },
1375
+ },
1376
+ });
1377
+ }
1378
+
1379
+ // Thinking handling
1380
+ if (chunk.thinking && chunk.thinking.length > 0) {
1381
+ // Find active thinking block or create new
1382
+ let idx = Array.from(state.activeBlocks.entries()).find(([, t]) => t === "thinking")?.[0];
1383
+ if (idx === undefined) {
1384
+ idx = state.nextBlockIndex++;
1385
+ state.activeBlocks.set(idx, "thinking");
1386
+ state.blockThinkingSent.set(idx, "");
1387
+ emit("content_block_start", {
1388
+ type: "content_block_start",
1389
+ index: idx,
1390
+ content_block: {
1391
+ type: "thinking",
1392
+ thinking: "",
1393
+ signature: chunk.thinkingSignature ?? "",
1394
+ },
1395
+ });
1396
+ }
1397
+
1398
+ // Delta
1399
+ const prev = state.blockThinkingSent.get(idx) ?? "";
1400
+ // chunk.thinking already delta from tracker, but we still track
1401
+ // For anthropic, thinking delta event
1402
+ emit("content_block_delta", {
1403
+ type: "content_block_delta",
1404
+ index: idx,
1405
+ delta: {
1406
+ type: "thinking_delta",
1407
+ thinking: chunk.thinking,
1408
+ },
1409
+ });
1410
+ state.blockThinkingSent.set(idx, prev + chunk.thinking);
1411
+ }
1412
+
1413
+ // Text handling
1414
+ if (chunk.text && chunk.text.length > 0) {
1415
+ let idx = Array.from(state.activeBlocks.entries()).find(([, t]) => t === "text")?.[0];
1416
+ // If thinking block active, we should keep text separate — create new block if first text
1417
+ // But also text may come after thinking stopped — we need new block
1418
+ const hasActiveText = state.activeBlocks.has(idx as number) && state.activeBlocks.get(idx as number) === "text";
1419
+ if (!hasActiveText) {
1420
+ // If there is active thinking and we now have text, we should stop thinking block first (per Claude spec thinking must precede text)
1421
+ const thinkingIdx = Array.from(state.activeBlocks.entries()).find(([, t]) => t === "thinking")?.[0];
1422
+ if (thinkingIdx !== undefined) {
1423
+ emit("content_block_stop", {
1424
+ type: "content_block_stop",
1425
+ index: thinkingIdx,
1426
+ });
1427
+ state.activeBlocks.delete(thinkingIdx);
1428
+ }
1429
+ idx = state.nextBlockIndex++;
1430
+ state.activeBlocks.set(idx, "text");
1431
+ state.blockTextSent.set(idx, "");
1432
+ emit("content_block_start", {
1433
+ type: "content_block_start",
1434
+ index: idx,
1435
+ content_block: {
1436
+ type: "text",
1437
+ text: "",
1438
+ },
1439
+ });
1440
+ }
1441
+
1442
+ emit("content_block_delta", {
1443
+ type: "content_block_delta",
1444
+ index: idx,
1445
+ delta: {
1446
+ type: "text_delta",
1447
+ text: chunk.text,
1448
+ },
1449
+ });
1450
+ const prev = state.blockTextSent.get(idx!) ?? "";
1451
+ state.blockTextSent.set(idx!, prev + chunk.text);
1452
+ }
1453
+
1454
+ // Tool calls
1455
+ if (chunk.toolCalls && chunk.toolCalls.length > 0) {
1456
+ for (const tc of chunk.toolCalls) {
1457
+ // One content block per tool_use
1458
+ // Check if tool already has block
1459
+ let idx: number | undefined;
1460
+ // We use tool id as map key for block index lookup
1461
+ for (const entry of Array.from(state.activeBlocks.entries())) {
1462
+ const bIdx = entry[0];
1463
+ const bType = entry[1];
1464
+ if (bType === "tool_use" && state.toolJsonSent.has(`idx_${bIdx}_id_${tc.id}`)) {
1465
+ idx = bIdx;
1466
+ break;
1467
+ }
1468
+ }
1469
+ const isNew = idx === undefined;
1470
+ if (isNew) {
1471
+ idx = state.nextBlockIndex++;
1472
+ state.activeBlocks.set(idx, "tool_use");
1473
+ state.toolJsonSent.set(`idx_${idx}_id_${tc.id}`, "");
1474
+ state.toolJsonSent.set(`id_${tc.id}`, String(idx)); // reverse lookup
1475
+ emit("content_block_start", {
1476
+ type: "content_block_start",
1477
+ index: idx,
1478
+ content_block: {
1479
+ type: "tool_use",
1480
+ id: tc.id,
1481
+ name: tc.name,
1482
+ input: {},
1483
+ },
1484
+ });
1485
+ }
1486
+
1487
+ // input_json delta
1488
+ // For Anthropic, partial_json is incremental input json
1489
+ const prevJson = state.toolJsonSent.get(`idx_${idx}_id_${tc.id}`) ?? "";
1490
+ const deltaJson = tc.isDelta ? tc.argsJson : extractDelta(tc.argsJson, prevJson) || tc.argsJson;
1491
+ if (deltaJson) {
1492
+ emit("content_block_delta", {
1493
+ type: "content_block_delta",
1494
+ index: idx,
1495
+ delta: {
1496
+ type: "input_json_delta",
1497
+ partial_json: deltaJson,
1498
+ },
1499
+ });
1500
+ state.toolJsonSent.set(`idx_${idx}_id_${tc.id}`, prevJson + deltaJson);
1501
+ }
1502
+ }
1503
+ }
1504
+
1505
+ // Finish handling
1506
+ if (chunk.finishReason || chunk.isFinished) {
1507
+ // Stop all active blocks
1508
+ for (const bIdx of Array.from(state.activeBlocks.keys())) {
1509
+ emit("content_block_stop", {
1510
+ type: "content_block_stop",
1511
+ index: bIdx,
1512
+ });
1513
+ }
1514
+ state.activeBlocks.clear();
1515
+
1516
+ // Map finish reason to Anthropic stop_reason
1517
+ let stopReason: string | null = "end_turn";
1518
+ switch (chunk.finishReason) {
1519
+ case "tool_calls":
1520
+ stopReason = "tool_use";
1521
+ break;
1522
+ case "length":
1523
+ stopReason = "max_tokens";
1524
+ break;
1525
+ case "content_filter":
1526
+ stopReason = "stop_sequence";
1527
+ break;
1528
+ default:
1529
+ stopReason = chunk.finishReason ? "end_turn" : "end_turn";
1530
+ }
1531
+
1532
+ emit("message_delta", {
1533
+ type: "message_delta",
1534
+ delta: {
1535
+ stop_reason: stopReason,
1536
+ stop_sequence: null,
1537
+ },
1538
+ usage: chunk.usageMetadata ?? { output_tokens: 0 },
1539
+ });
1540
+
1541
+ emit("message_stop", {
1542
+ type: "message_stop",
1543
+ });
1544
+
1545
+ state.finished = true;
1546
+ }
1547
+
1548
+ return out;
1549
+ }
1550
+
1551
+ /**
1552
+ * Factory for Anthropic streaming transformer compatible with opencode
1553
+ *
1554
+ * @param opts - model options
1555
+ * @param cache - LRU signature cache
1556
+ */
1557
+ export function createAnthropicStreamingTransformer(
1558
+ opts: AnthropicChunkOptions,
1559
+ cache: ReplaySignatureCache = globalThinkingSignatureCache,
1560
+ ): {
1561
+ tracker: StreamingSessionTracker;
1562
+ anthropicState: AnthropicStateMachine;
1563
+ transform: (chunk: NormalizedChunk) => string[];
1564
+ final: () => string[];
1565
+ } {
1566
+ const tracker = new StreamingSessionTracker(cache);
1567
+ const anthropicState = new AnthropicStateMachine();
1568
+
1569
+ return {
1570
+ tracker,
1571
+ anthropicState,
1572
+ transform: (chunk: NormalizedChunk) => {
1573
+ const deduped = tracker.push(chunk);
1574
+ // If nothing new and not finish, skip
1575
+ if (!deduped.text && !deduped.thinking && !deduped.toolCalls && !deduped.finishReason && !deduped.isFinished) {
1576
+ return [];
1577
+ }
1578
+ return transformToAnthropic(deduped, opts, anthropicState);
1579
+ },
1580
+ final: () => {
1581
+ const synthetic = tracker.createSyntheticFinish("stop");
1582
+ if (!synthetic) return [];
1583
+ return transformToAnthropic(synthetic, opts, anthropicState);
1584
+ },
1585
+ };
1586
+ }
1587
+
1588
+ // ---------------------------------------------------------------------------
1589
+ // Auto-recovery — exponential retry and synthetic finish injection
1590
+ // ---------------------------------------------------------------------------
1591
+
1592
+ export interface RetryOptions {
1593
+ /** Max attempts including first */
1594
+ maxRetries?: number;
1595
+ /** Base delay ms */
1596
+ baseMs?: number;
1597
+ /** Factor */
1598
+ factor?: number;
1599
+ /** Max delay ms */
1600
+ maxMs?: number;
1601
+ /** Which status codes trigger retry */
1602
+ retryableStatus?: number[];
1603
+ }
1604
+
1605
+ /**
1606
+ * Determines if error/status is retryable.
1607
+ * Observer pattern: checks 429, 5xx, and network failures.
1608
+ *
1609
+ * @param status - HTTP status or undefined for network error
1610
+ */
1611
+ // local variant: diverges from models (adds opts.retryableStatus and treats undefined as retryable network failure; models.isRetryableStatus takes a plain status and hardcodes 403/404/408/429/5xx)
1612
+ export function isRetryableNetworkStatus(status: number | undefined, opts?: RetryOptions): boolean {
1613
+ if (status === undefined) return true; // network failure retryable
1614
+ const list = opts?.retryableStatus ?? (RETRYABLE_STATUS_CODES as unknown as number[]);
1615
+ return list.includes(status);
1616
+ }
1617
+
1618
+ /**
1619
+ * Fetches with exponential backoff + jitter.
1620
+ * Uses only node builtins + global fetch.
1621
+ * Third-person observer: it watches fetch, retries on transient errors.
1622
+ *
1623
+ * @param input - RequestInfo
1624
+ * @param init - RequestInit
1625
+ * @param retryOpts - backoff options
1626
+ * @returns Response
1627
+ * @throws last error after retries exhausted
1628
+ */
1629
+ export async function fetchWithExponentialRetry(
1630
+ input: Parameters<typeof fetch>[0],
1631
+ init?: RequestInit,
1632
+ retryOpts: RetryOptions = {},
1633
+ ): Promise<Response> {
1634
+ const maxRetries = retryOpts.maxRetries ?? MAX_RETRIES;
1635
+ const baseMs = retryOpts.baseMs ?? BACKOFF_BASE_MS;
1636
+ const factor = retryOpts.factor ?? BACKOFF_FACTOR;
1637
+ const maxMs = retryOpts.maxMs ?? BACKOFF_MAX_MS;
1638
+
1639
+ let attempt = 0;
1640
+ let lastError: unknown;
1641
+
1642
+ while (attempt <= maxRetries) {
1643
+ try {
1644
+ const res = await fetch(input, init);
1645
+
1646
+ if (res.ok) return res;
1647
+
1648
+ // Non-ok: check if retryable
1649
+ if (!isRetryableNetworkStatus(res.status, retryOpts) || attempt >= maxRetries) {
1650
+ return res; // return non-retryable response for caller to handle
1651
+ }
1652
+
1653
+ // Retryable status — consume body to free connection before retry
1654
+ try {
1655
+ await res.text();
1656
+ } catch {
1657
+ // ignore
1658
+ }
1659
+
1660
+ lastError = new Error(`retryable status ${res.status}`);
1661
+ } catch (err) {
1662
+ lastError = err;
1663
+ if (attempt >= maxRetries) break;
1664
+ // network error — retryable unless explicitly non-retryable
1665
+ }
1666
+
1667
+ // Backoff before next attempt
1668
+ if (attempt < maxRetries) {
1669
+ const delay = Math.min(baseMs * Math.pow(factor, attempt), maxMs);
1670
+ await sleepWithJitter(delay);
1671
+ }
1672
+
1673
+ attempt++;
1674
+ }
1675
+
1676
+ throw lastError instanceof Error ? lastError : new Error(String(lastError ?? "fetch retry exhausted"));
1677
+ }
1678
+
1679
+ /**
1680
+ * Creates synthetic finish chunk when stream closes abruptly.
1681
+ * Observer injects `stop` finish to keep opencode SDK happy.
1682
+ *
1683
+ * @param tracker - session tracker to know current state
1684
+ * @param reason - finish reason, default stop
1685
+ * @returns normalized finish chunk or null if already finished
1686
+ */
1687
+ export function injectSyntheticFinish(
1688
+ tracker: StreamingSessionTracker,
1689
+ reason: string = "stop",
1690
+ ): NormalizedChunk | null {
1691
+ return tracker.createSyntheticFinish(reason);
1692
+ }
1693
+
1694
+ /**
1695
+ * Wraps streaming response reading with auto-recovery.
1696
+ * If reader throws before finish, retries whole fetch (exponential).
1697
+ * On final failure, injects synthetic finish so UI doesn't hang.
1698
+ *
1699
+ * @param fetchFn - function that returns Promise<Response> (fresh fetch each attempt)
1700
+ * @param onChunk - callback for each normalized chunk (deduped)
1701
+ * @param retryOpts - retry options for fetch phase
1702
+ * @param tracker - optional tracker (if not provided new one created)
1703
+ */
1704
+ export async function withAutoRecoveryStreaming(
1705
+ fetchFn: () => Promise<Response>,
1706
+ onChunk: (chunk: NormalizedChunk) => void | Promise<void>,
1707
+ retryOpts: RetryOptions = {},
1708
+ tracker: StreamingSessionTracker = new StreamingSessionTracker(globalThinkingSignatureCache),
1709
+ ): Promise<void> {
1710
+ const maxRetries = retryOpts.maxRetries ?? MAX_RETRIES;
1711
+ let attempt = 0;
1712
+
1713
+ while (attempt <= maxRetries) {
1714
+ let response: Response;
1715
+ try {
1716
+ response = await fetchWithExponentialRetry(
1717
+ // dummy — actual fetchFn does request construction
1718
+ // We call fetchFn directly but reuse retry logic for network: we wrap fetchFn with backoff here
1719
+ // So first we try fetchFn()
1720
+ // fetchWithExponentialRetry expects input, not fn — we implement manual retry loop for fetchFn
1721
+ // Instead we directly call fetchFn and apply retry inside catch below
1722
+ // For simplicity we call fetchFn here and if it throws we retry
1723
+ // This inner try will rely on outer loop for backoff
1724
+ "" as unknown as Parameters<typeof fetch>[0],
1725
+ undefined,
1726
+ retryOpts,
1727
+ ).catch(() => {
1728
+ // unreachable because we pass empty input — we will replace with fetchFn call
1729
+ return null as any;
1730
+ });
1731
+ // Actually we need to call fetchFn, not fetchWithExponentialRetry with dummy
1732
+ // Re-do: override response with fetchFn result
1733
+ response = await fetchFn();
1734
+ } catch (err) {
1735
+ if (attempt >= maxRetries) {
1736
+ // Final failure — inject synthetic finish and exit
1737
+ const synthetic = tracker.createSyntheticFinish("stop");
1738
+ if (synthetic) await onChunk(synthetic);
1739
+ throw err;
1740
+ }
1741
+ const delay = Math.min(
1742
+ (retryOpts.baseMs ?? BACKOFF_BASE_MS) * Math.pow(retryOpts.factor ?? BACKOFF_FACTOR, attempt),
1743
+ retryOpts.maxMs ?? BACKOFF_MAX_MS,
1744
+ );
1745
+ await sleepWithJitter(delay);
1746
+ attempt++;
1747
+ continue;
1748
+ }
1749
+
1750
+ if (!response.ok) {
1751
+ if (isRetryableNetworkStatus(response.status, retryOpts) && attempt < maxRetries) {
1752
+ try {
1753
+ await response.text();
1754
+ } catch {
1755
+ // ignore
1756
+ }
1757
+ const delay = Math.min(
1758
+ (retryOpts.baseMs ?? BACKOFF_BASE_MS) * Math.pow(retryOpts.factor ?? BACKOFF_FACTOR, attempt),
1759
+ retryOpts.maxMs ?? BACKOFF_MAX_MS,
1760
+ );
1761
+ await sleepWithJitter(delay);
1762
+ attempt++;
1763
+ continue;
1764
+ }
1765
+ // Non-retryable error — propagate response error as exception with body preview
1766
+ let bodyPreview = "";
1767
+ try {
1768
+ bodyPreview = (await response.text()).slice(0, 2000);
1769
+ } catch {
1770
+ bodyPreview = "<no body>";
1771
+ }
1772
+ throw new Error(`cloudcode-pa ${response.status} ${response.statusText}: ${bodyPreview}`);
1773
+ }
1774
+
1775
+ // Stream reading phase — try to read all
1776
+ try {
1777
+ for await (const normalized of readSSEStream(response, tracker)) {
1778
+ await onChunk(normalized);
1779
+ }
1780
+
1781
+ // Normal end — ensure finish injected if missing
1782
+ if (!tracker.isFinished) {
1783
+ const synthetic = tracker.createSyntheticFinish("stop");
1784
+ if (synthetic) await onChunk(synthetic);
1785
+ }
1786
+
1787
+ return; // success
1788
+ } catch (streamErr) {
1789
+ // Stream broke mid-way, retry if not finished and attempts left
1790
+ if (attempt >= maxRetries || tracker.isFinished) {
1791
+ if (!tracker.isFinished) {
1792
+ const synthetic = tracker.createSyntheticFinish("stop");
1793
+ if (synthetic) await onChunk(synthetic);
1794
+ }
1795
+ throw streamErr;
1796
+ }
1797
+ const delay = Math.min(
1798
+ (retryOpts.baseMs ?? BACKOFF_BASE_MS) * Math.pow(retryOpts.factor ?? BACKOFF_FACTOR, attempt),
1799
+ retryOpts.maxMs ?? BACKOFF_MAX_MS,
1800
+ );
1801
+ await sleepWithJitter(delay);
1802
+ attempt++;
1803
+ // Continue retry loop — will re-fetch and continue? Note: we lost progress but tracker preserves sent state for dedup
1804
+ continue;
1805
+ }
1806
+ }
1807
+ }
1808
+
1809
+ // ---------------------------------------------------------------------------
1810
+ // High-level SSE stream reader — direct cloudcode-pa
1811
+ // ---------------------------------------------------------------------------
1812
+
1813
+ /**
1814
+ * Async generator that reads a fetch Response body (cloudcode-pa alt=sse)
1815
+ * and yields normalized chunks with preserved thinking blocks.
1816
+ *
1817
+ * The observer directly consumes https://cloudcode-pa.googleapis.com
1818
+ * (or daily-.../sandbox) without localhost v1 proxy.
1819
+ *
1820
+ * @param response - fetch Response with body ReadableStream
1821
+ * @param tracker - optional tracker for delta preservation (new one if not provided)
1822
+ * @yields NormalizedChunk (deduped delta already applied via tracker)
1823
+ */
1824
+ export async function* readSSEStream(
1825
+ response: Response,
1826
+ tracker: StreamingSessionTracker = new StreamingSessionTracker(globalThinkingSignatureCache),
1827
+ ): AsyncGenerator<NormalizedChunk> {
1828
+ if (!response || !response.body) {
1829
+ throw new TypeError("readSSEStream requires Response with body");
1830
+ }
1831
+
1832
+ const parserState = createSSEParserState();
1833
+ const decoder = new TextDecoder("utf-8");
1834
+
1835
+ // Node fetch Response.body is a ReadableStream (web)
1836
+ const reader = (response.body as any).getReader() as ReadableStreamDefaultReader<Uint8Array>;
1837
+
1838
+ if (!reader || typeof reader.read !== "function") {
1839
+ throw new Error("Response body does not implement getReader() — unsupported environment");
1840
+ }
1841
+
1842
+ try {
1843
+ while (true) {
1844
+ let result: { done: boolean; value?: Uint8Array | undefined };
1845
+ try {
1846
+ result = await reader.read();
1847
+ } catch (readErr) {
1848
+ // Network mid-stream failure — let outer auto-recovery handle
1849
+ const wrapped = new Error(`stream read failed: ${(readErr as Error).message}`);
1850
+ (wrapped as any).cause = readErr;
1851
+ throw wrapped;
1852
+ }
1853
+
1854
+ if (result.done) break;
1855
+
1856
+ const value = result.value;
1857
+ if (!value || value.length === 0) continue;
1858
+
1859
+ let textChunk: string;
1860
+ try {
1861
+ textChunk = decoder.decode(value, { stream: true });
1862
+ } catch (decErr) {
1863
+ console.warn("[streaming] TextDecoder failed, skipping chunk", decErr);
1864
+ continue;
1865
+ }
1866
+
1867
+ let events: ParsedSSEEvent[];
1868
+ try {
1869
+ events = parseSSEChunk(textChunk, parserState);
1870
+ } catch (parseErr) {
1871
+ console.warn("[streaming] parseSSEChunk failed", parseErr);
1872
+ continue;
1873
+ }
1874
+
1875
+ if (events.length === 0) continue;
1876
+
1877
+ for (const ev of events) {
1878
+ // Skip [DONE] sentinel here — normalize will handle but we also short-circuit
1879
+ if (ev.data.trim() === SSE_DONE_SENTINEL) {
1880
+ const chunks = normalizePayloads([ev.data]);
1881
+ for (const c of chunks) {
1882
+ const deduped = tracker.push(c);
1883
+ yield deduped;
1884
+ }
1885
+ continue;
1886
+ }
1887
+
1888
+ const normalizedList = normalizePayloads([ev.data]);
1889
+
1890
+ for (const norm of normalizedList) {
1891
+ const deduped = tracker.push(norm);
1892
+ // Yield only if has something new or finish
1893
+ if (
1894
+ deduped.text ||
1895
+ deduped.thinking ||
1896
+ (deduped.toolCalls && deduped.toolCalls.length > 0) ||
1897
+ deduped.finishReason ||
1898
+ deduped.isFinished
1899
+ ) {
1900
+ yield deduped;
1901
+ } else if (deduped.isFinished) {
1902
+ yield deduped;
1903
+ }
1904
+ }
1905
+ }
1906
+ }
1907
+
1908
+ // Flush decoder remaining
1909
+ try {
1910
+ const remaining = decoder.decode();
1911
+ if (remaining) {
1912
+ const events = parseSSEChunk(remaining, parserState);
1913
+ for (const ev of events) {
1914
+ const normalizedList = normalizePayloads([ev.data]);
1915
+ for (const norm of normalizedList) {
1916
+ yield tracker.push(norm);
1917
+ }
1918
+ }
1919
+ }
1920
+ } catch {
1921
+ // ignore flush errors
1922
+ }
1923
+
1924
+ // If buffer still has data without trailing \n\n, try to parse it as final block (tolerant)
1925
+ if (parserState.buffer.trim().length > 0) {
1926
+ const finalBlock = parserState.buffer.trim();
1927
+ // Attempt to treat it as data line even without delimiter
1928
+ // Remove possible "data:" prefix if present
1929
+ let dataPayload = finalBlock;
1930
+ if (dataPayload.startsWith("data:")) {
1931
+ dataPayload = dataPayload.slice(5).trimStart();
1932
+ }
1933
+ if (dataPayload && dataPayload !== SSE_DONE_SENTINEL) {
1934
+ const normalizedList = normalizePayloads([dataPayload]);
1935
+ for (const norm of normalizedList) {
1936
+ yield tracker.push(norm);
1937
+ }
1938
+ }
1939
+ parserState.buffer = "";
1940
+ }
1941
+ } finally {
1942
+ try {
1943
+ reader.releaseLock();
1944
+ } catch {
1945
+ // ignore
1946
+ }
1947
+ }
1948
+ }
1949
+
1950
+ /**
1951
+ * Convenience: stream cloudcode-pa response and transform to OpenAI SSE lines.
1952
+ * Used by opencode plugin fetch interceptor.
1953
+ *
1954
+ * @param response - fetch Response from cloudcode-pa
1955
+ * @param openAIOpts - model info
1956
+ * @param cache - optional LRU cache
1957
+ * @returns AsyncGenerator<string> of `data: {...}\n\n` lines
1958
+ */
1959
+ export async function* streamToOpenAI(
1960
+ response: Response,
1961
+ openAIOpts: OpenAIChunkOptions,
1962
+ cache: ReplaySignatureCache = globalThinkingSignatureCache,
1963
+ ): AsyncGenerator<string> {
1964
+ const tracker = new StreamingSessionTracker(cache);
1965
+ const fixedId = openAIOpts.id ?? generateChatId();
1966
+ const created = openAIOpts.created ?? Math.floor(Date.now() / 1000);
1967
+
1968
+ for await (const norm of readSSEStream(response, tracker)) {
1969
+ const lines = transformToOpenAI(norm, { ...openAIOpts, id: fixedId, created }, tracker);
1970
+ for (const line of lines) yield line;
1971
+ }
1972
+
1973
+ if (!tracker.isFinished) {
1974
+ const synthetic = tracker.createSyntheticFinish("stop");
1975
+ if (synthetic) {
1976
+ const lines = transformToOpenAI(synthetic, { ...openAIOpts, id: fixedId, created }, tracker);
1977
+ for (const line of lines) yield line;
1978
+ }
1979
+ }
1980
+
1981
+ // Final [DONE] for OpenAI clients
1982
+ yield `data: ${SSE_DONE_SENTINEL}\n\n`;
1983
+ }
1984
+
1985
+ /**
1986
+ * Convenience: stream cloudcode-pa response and transform to Anthropic SSE lines.
1987
+ *
1988
+ * @param response - fetch Response
1989
+ * @param anthropicOpts - model info
1990
+ * @param cache - optional LRU cache
1991
+ * @returns AsyncGenerator<string> of `event: ...\ndata: ...\n\n`
1992
+ */
1993
+ export async function* streamToAnthropic(
1994
+ response: Response,
1995
+ anthropicOpts: AnthropicChunkOptions,
1996
+ cache: ReplaySignatureCache = globalThinkingSignatureCache,
1997
+ ): AsyncGenerator<string> {
1998
+ const tracker = new StreamingSessionTracker(cache);
1999
+ const anthropicState = new AnthropicStateMachine();
2000
+
2001
+ for await (const norm of readSSEStream(response, tracker)) {
2002
+ const lines = transformToAnthropic(norm, anthropicOpts, anthropicState);
2003
+ for (const line of lines) yield line;
2004
+ }
2005
+
2006
+ if (!tracker.isFinished) {
2007
+ const synthetic = tracker.createSyntheticFinish("stop");
2008
+ if (synthetic) {
2009
+ const lines = transformToAnthropic(synthetic, anthropicOpts, anthropicState);
2010
+ for (const line of lines) yield line;
2011
+ }
2012
+ }
2013
+ }
2014
+
2015
+ // ---------------------------------------------------------------------------
2016
+ // Merged: canonical bypass constants (from ONDA 4 streaming module)
2017
+ // ---------------------------------------------------------------------------
2018
+
2019
+ // GEMINI_CLI_OAUTH_CLIENT_ID / GEMINI_CLI_OAUTH_CLIENT_SECRET / PROJECT_FALLBACK are
2020
+ // imported from constants.js (identical values) and re-exported above.
2021
+
2022
+ /** Production Cloud Code Assist base endpoint. */
2023
+
2024
+ /** Exact User-Agent sent by Gemini CLI 0.57.0. */
2025
+
2026
+ // local variant: diverges from constants (constants.X_GOOG_API_CLIENT is the antigravity/{ver} gl-node gax/grpc chain; this is the bare gl-node value)
2027
+ /** X-Goog-Api-Client value observed on real Gemini CLI traffic. */
2028
+
2029
+ /** Client-Metadata header string used by Gemini CLI bypass requests. */
2030
+
2031
+ // v2.1.15 Phase A: MODELS_2026 (the 11-id legacy bypass list) and the
2032
+ // ModelId2026 union moved to models.ts (MODELS_2026_LEGACY / ModelId2026) —
2033
+ // imported under the historical name and re-exported so the public surface
2034
+ // is unchanged.
2035
+
2036
+
2037
+ export { MODELS_2026 };
2038
+ export type { ModelId2026 };
2039
+
2040
+ // ---------------------------------------------------------------------------
2041
+ // Merged: raw cloudcode-pa payload types
2042
+ // ---------------------------------------------------------------------------
2043
+
2044
+ /**
2045
+ * Raw part shape emitted by cloudcode-pa (Gemini core with Claude interop).
2046
+ */
2047
+ export interface RawPart {
2048
+ text?: string;
2049
+ thought?: boolean;
2050
+ thoughtSignature?: string;
2051
+ functionCall?: { name: string; args?: Record<string, unknown> | string; id?: string };
2052
+ inlineData?: { mimeType: string; data: string };
2053
+ [k: string]: unknown;
2054
+ }
2055
+
2056
+ /**
2057
+ * Raw content container — either wrapped parts or bare text.
2058
+ */
2059
+ export interface RawContent {
2060
+ role?: string;
2061
+ parts?: RawPart[];
2062
+ text?: string;
2063
+ }
2064
+
2065
+ /**
2066
+ * Raw candidate inside a cloudcode-pa chunk.
2067
+ */
2068
+ export interface RawCandidate {
2069
+ content?: RawContent;
2070
+ finishReason?: string;
2071
+ finishMessage?: string;
2072
+ index?: number;
2073
+ tokenCount?: number;
2074
+ safetyRatings?: unknown[];
2075
+ parts?: RawPart[];
2076
+ text?: string;
2077
+ thought?: boolean;
2078
+ thoughtSignature?: string;
2079
+ }
2080
+
2081
+ /**
2082
+ * Heterogeneous raw chunk from v1internal:streamGenerateContent.
2083
+ */
2084
+ export interface CloudCodeRawChunk {
2085
+ candidates?: RawCandidate[];
2086
+ content?: RawContent;
2087
+ parts?: RawPart[];
2088
+ response?: CloudCodeRawChunk;
2089
+ usageMetadata?: {
2090
+ promptTokenCount?: number;
2091
+ candidatesTokenCount?: number;
2092
+ totalTokenCount?: number;
2093
+ thoughtsTokenCount?: number;
2094
+ };
2095
+ model?: string;
2096
+ modelVersion?: string;
2097
+ thought?: string;
2098
+ thoughtSignature?: string;
2099
+ [k: string]: unknown;
2100
+ }
2101
+
2102
+ // ---------------------------------------------------------------------------
2103
+ // Merged: normalized payload types
2104
+ // ---------------------------------------------------------------------------
2105
+
2106
+ /** Kind discriminator for {@link NormalizedPart}. */
2107
+ export type NormalizedPartKind = "text" | "thinking" | "thought_signature" | "function_call" | "inline_data";
2108
+
2109
+ /**
2110
+ * One normalized part with provenance indexes and optional signature.
2111
+ */
2112
+ export interface NormalizedPart {
2113
+ kind: NormalizedPartKind;
2114
+ text: string;
2115
+ thought: boolean;
2116
+ thoughtSignature?: string | undefined;
2117
+ functionCall?: { name: string; args: string; id: string };
2118
+ rawIndex: number;
2119
+ candidateIndex: number;
2120
+ finishReason?: string | undefined;
2121
+ }
2122
+
2123
+ /**
2124
+ * Fully normalized payload: parts plus finish/usage metadata.
2125
+ */
2126
+ export interface NormalizedPayload {
2127
+ parts: NormalizedPart[];
2128
+ finishReason?: string | undefined;
2129
+ usage?: CloudCodeRawChunk["usageMetadata"] | undefined;
2130
+ model?: string | undefined;
2131
+ isThinkingChunk: boolean;
2132
+ hasFinish: boolean;
2133
+ raw: CloudCodeRawChunk;
2134
+ }
2135
+
2136
+ // ---------------------------------------------------------------------------
2137
+ // Merged: streaming state + OpenAI / Anthropic wire types
2138
+ // ---------------------------------------------------------------------------
2139
+
2140
+ /**
2141
+ * Mutable state shared by the stateful OpenAI / Anthropic transformers.
2142
+ */
2143
+ export interface StreamingState {
2144
+ completionId: string;
2145
+ model: string;
2146
+ created: number;
2147
+ sentRole: boolean;
2148
+ accumulatedText: string;
2149
+ accumulatedThinking: string;
2150
+ lastFinishReason?: string | undefined;
2151
+ insideThinking: boolean;
2152
+ anthropicBlockIndex: number;
2153
+ anthropicBlockKinds: Map<number, NormalizedPartKind>;
2154
+ toolCallIdCounter: number;
2155
+ pendingSignatures: string[];
2156
+ }
2157
+
2158
+ /**
2159
+ * OpenAI chat.completion.chunk wire type emitted by the transformer.
2160
+ */
2161
+ export interface OpenAIChatChunk {
2162
+ id: string;
2163
+ object: "chat.completion.chunk";
2164
+ created: number;
2165
+ model: string;
2166
+ choices: Array<{
2167
+ index: number;
2168
+ delta: {
2169
+ role?: "assistant";
2170
+ content?: string;
2171
+ reasoning_content?: string;
2172
+ reasoning?: string;
2173
+ tool_calls?: Array<{
2174
+ index: number;
2175
+ id: string;
2176
+ type: "function";
2177
+ function: { name: string; arguments: string };
2178
+ }>;
2179
+ };
2180
+ finish_reason: string | null;
2181
+ }>;
2182
+ usage?: {
2183
+ prompt_tokens?: number;
2184
+ completion_tokens?: number;
2185
+ total_tokens?: number;
2186
+ completion_tokens_details?: { reasoning_tokens?: number };
2187
+ };
2188
+ }
2189
+
2190
+ /**
2191
+ * Anthropic-style SSE event descriptor.
2192
+ */
2193
+ export interface AnthropicSSEEvent {
2194
+ type: string;
2195
+ data?: unknown;
2196
+ sse?: string;
2197
+ }
2198
+
2199
+ /**
2200
+ * Diagnostic result shape for remainder-based SSE parsing.
2201
+ */
2202
+ export interface ParseSSEChunkResult {
2203
+ payloads: CloudCodeRawChunk[];
2204
+ remainder: string;
2205
+ consumedBytes: number;
2206
+ errors: string[];
2207
+ }
2208
+
2209
+ // ---------------------------------------------------------------------------
2210
+ // Merged: multi-turn thinking signature LRU cache (entry-based)
2211
+ // ---------------------------------------------------------------------------
2212
+
2213
+ /**
2214
+ * Byte-wise FNV-1a 32-bit hash of a UTF-8 string, rendered as 8 hex chars.
2215
+ * Shared by {@link ThinkingSignatureLRUCache.hashThinking} and
2216
+ * {@link buildHostSessionId} (v2.1.16 internal-dedup: the two identical
2217
+ * inline loops were extracted into this single private helper). Distinct from
2218
+ * core.js fnv1a32, which hashes UTF-16 code units.
2219
+ *
2220
+ * @param value - input string
2221
+ * @returns 8-char lowercase hex digest
2222
+ */
2223
+ function fnv1a32HexBytes(value: string): string {
2224
+ let h = 0x811c9dc5;
2225
+ const buf = Buffer.from(value, "utf8");
2226
+ for (let i = 0; i < buf.length; i++) {
2227
+ h ^= buf[i]!;
2228
+ h = Math.imul(h, 0x01000193) >>> 0;
2229
+ }
2230
+ return h.toString(16).padStart(8, "0");
2231
+ }
2232
+
2233
+ /**
2234
+ * Cached signature entry with turn/model provenance.
2235
+ */
2236
+ export interface ReplaySignatureEntry {
2237
+ signature: string;
2238
+ thinkingText: string;
2239
+ model: string;
2240
+ timestamp: number;
2241
+ turnId: string;
2242
+ tokenCount?: number | undefined;
2243
+ }
2244
+
2245
+ /**
2246
+ * Entry-based LRU cache for thinking signatures across turns.
2247
+ * Preserves thoughtSignature values that must be replayed on multi-turn
2248
+ * requests for gemini-3-pro / claude thinking models.
2249
+ *
2250
+ * Kept alongside {@link ReplaySignatureCache}, which maps plain text hashes
2251
+ * to signature strings.
2252
+ */
2253
+ export class ThinkingSignatureLRUCache {
2254
+ private readonly maxEntries: number;
2255
+ private readonly map: Map<string, ReplaySignatureEntry>;
2256
+
2257
+ constructor(maxEntries = 100) {
2258
+ this.maxEntries = maxEntries;
2259
+ this.map = new Map();
2260
+ }
2261
+
2262
+ private touch(key: string): void {
2263
+ const entry = this.map.get(key);
2264
+ if (!entry) return;
2265
+ // LRU: re-insert at the end (most recently used)
2266
+ this.map.delete(key);
2267
+ this.map.set(key, entry);
2268
+ }
2269
+
2270
+ get(key: string): ReplaySignatureEntry | undefined {
2271
+ const v = this.map.get(key);
2272
+ if (v) this.touch(key);
2273
+ return v;
2274
+ }
2275
+
2276
+ set(key: string, entry: ReplaySignatureEntry): void {
2277
+ if (this.map.has(key)) this.map.delete(key);
2278
+ this.map.set(key, entry);
2279
+ while (this.map.size > this.maxEntries) {
2280
+ const oldest = this.map.keys().next().value as string;
2281
+ this.map.delete(oldest);
2282
+ }
2283
+ }
2284
+
2285
+ /**
2286
+ * Short FNV-1a hex hash of thinking text, used as key when no turnId exists.
2287
+ */
2288
+ static hashThinking(text: string): string {
2289
+ return fnv1a32HexBytes(text);
2290
+ }
2291
+
2292
+ /**
2293
+ * Adds an entry derived from a payload fragment and returns the cache key.
2294
+ */
2295
+ addFromPayload(payload: {
2296
+ signature: string;
2297
+ thinkingText?: string;
2298
+ model?: string;
2299
+ turnId?: string;
2300
+ tokenCount?: number;
2301
+ }): string {
2302
+ const keyBase = payload.turnId ?? ThinkingSignatureLRUCache.hashThinking(payload.thinkingText ?? payload.signature);
2303
+ const entry: ReplaySignatureEntry = {
2304
+ signature: payload.signature,
2305
+ thinkingText: payload.thinkingText ?? "",
2306
+ model: payload.model ?? UA_DEFAULT_MODEL,
2307
+ timestamp: Date.now(),
2308
+ turnId: keyBase,
2309
+ tokenCount: payload.tokenCount,
2310
+ };
2311
+ this.set(keyBase, entry);
2312
+ return keyBase;
2313
+ }
2314
+
2315
+ findByModel(model: string): ReplaySignatureEntry[] {
2316
+ const out: ReplaySignatureEntry[] = [];
2317
+ for (const e of Array.from(this.map.values())) {
2318
+ if (e.model === model || e.model.includes(model) || model.includes(e.model)) out.push(e);
2319
+ }
2320
+ return out;
2321
+ }
2322
+
2323
+ clear(): void {
2324
+ this.map.clear();
2325
+ }
2326
+
2327
+ get size(): number {
2328
+ return this.map.size;
2329
+ }
2330
+
2331
+ entries(): IterableIterator<[string, ReplaySignatureEntry]> {
2332
+ return this.map.entries();
2333
+ }
2334
+
2335
+ toJSON(): Record<string, ReplaySignatureEntry> {
2336
+ const obj: Record<string, ReplaySignatureEntry> = {};
2337
+ for (const kv of Array.from(this.map.entries())) obj[kv[0]] = kv[1];
2338
+ return obj;
2339
+ }
2340
+ }
2341
+
2342
+ /**
2343
+ * Global singleton of the entry-based LRU cache. Kept alongside the text-hash
2344
+ * singleton {@link globalThinkingSignatureCache}.
2345
+ */
2346
+ export const globalThinkingSignatureLRUCache = new ThinkingSignatureLRUCache(100);
2347
+
2348
+ // ---------------------------------------------------------------------------
2349
+ // Merged: lightweight SSE parser utilities (v4/v5)
2350
+ // v2.1.16 dead-code cleanup: the exported LRU class (byte-identical twin of
2351
+ // core.ts's LRU, zero instantiations here) and the barrel-only compat trio
2352
+ // signatureCache / saveSignature / getSignature (zero consumers repo-wide)
2353
+ // were deleted; core.js keeps the shared LRU instance (thinkingCache).
2354
+ // ---------------------------------------------------------------------------
2355
+
2356
+ /**
2357
+ * Minimal SSE event shape used by the lightweight parser utilities.
2358
+ */
2359
+ export type sseEvent = { data: string };
2360
+
2361
+ /**
2362
+ * Linear-time strip of SDK-injected `[cache_control...]` markers (regex-free;
2363
+ * the former /\[cache_control.*?\]/g replace was polynomial on uncontrolled input).
2364
+ */
2365
+ export const stripCacheControlMarkers = (text: string): string => {
2366
+ let out = text;
2367
+ let idx = out.indexOf("[cache_control");
2368
+ while (idx !== -1) {
2369
+ const end = out.indexOf("]", idx);
2370
+ if (end === -1) break;
2371
+ out = out.slice(0, idx) + out.slice(end + 1);
2372
+ idx = out.indexOf("[cache_control", idx);
2373
+ }
2374
+ return out;
2375
+ };
2376
+
2377
+ /**
2378
+ * Strips SDK-injected cache_control markers from a thinking block payload.
2379
+ *
2380
+ * @param p - payload possibly containing `thinking`
2381
+ * @param keepThinking - kept for API compatibility; both branches preserve thinking
2382
+ * @returns payload with cleaned thinking text
2383
+ */
2384
+ export const handleThinkingBlock = (p: any, keepThinking = false): any => {
2385
+ if (!p.thinking) return p;
2386
+ // Filter sdk-injected cache_control markers
2387
+ const cleaned = {
2388
+ ...p.thinking,
2389
+ text: typeof p.thinking.text === "string" ? stripCacheControlMarkers(p.thinking.text) : p.thinking.text,
2390
+ };
2391
+ if (keepThinking) {
2392
+ return { ...p, thinking: cleaned };
2393
+ }
2394
+ return { ...p, thinking: cleaned };
2395
+ };
2396
+
2397
+ // ---------------------------------------------------------------------------
2398
+ // Merged: tolerant SSE data parser (remainder-based)
2399
+ // ---------------------------------------------------------------------------
2400
+
2401
+ function safeJsonParseArray(text: string): { objects: CloudCodeRawChunk[]; remainder: string; errors: string[] } {
2402
+ const objects: CloudCodeRawChunk[] = [];
2403
+ const errors: string[] = [];
2404
+ let remainder = text;
2405
+
2406
+ // Trim BOM
2407
+ if (remainder.charCodeAt(0) === 0xfeff) remainder = remainder.slice(1);
2408
+
2409
+ let buffer = remainder;
2410
+ remainder = "";
2411
+
2412
+ const lines = buffer.split(/\r?\n/);
2413
+ let jsonAccumulator = "";
2414
+ let insideDataBlock = false;
2415
+ let dataPrefixFound = false;
2416
+
2417
+ const flushAccumulator = (): { parsed: CloudCodeRawChunk[]; leftover: string } => {
2418
+ const trimmed = jsonAccumulator.trim();
2419
+ if (!trimmed) return { parsed: [], leftover: "" };
2420
+ const parsed: CloudCodeRawChunk[] = [];
2421
+ let leftover = "";
2422
+
2423
+ // JSON array form [...]
2424
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
2425
+ try {
2426
+ const arr = JSON.parse(trimmed) as unknown;
2427
+ if (Array.isArray(arr)) {
2428
+ for (const item of arr) {
2429
+ if (item && typeof item === "object") parsed.push(item as CloudCodeRawChunk);
2430
+ }
2431
+ return { parsed, leftover: "" };
2432
+ }
2433
+ } catch {
2434
+ // incomplete array -> treat as streamed object below
2435
+ }
2436
+ }
2437
+
2438
+ // Concatenated objects like }{ or }\n{ — balanced-brace incremental scan
2439
+ let depth = 0;
2440
+ let inString = false;
2441
+ let escape = false;
2442
+ let start = -1;
2443
+ for (let i = 0; i < trimmed.length; i++) {
2444
+ const ch = trimmed[i]!;
2445
+ if (escape) {
2446
+ escape = false;
2447
+ continue;
2448
+ }
2449
+ if (ch === "\\" && inString) {
2450
+ escape = true;
2451
+ continue;
2452
+ }
2453
+ if (ch === '"') {
2454
+ inString = !inString;
2455
+ continue;
2456
+ }
2457
+ if (inString) continue;
2458
+ if (ch === "{" || ch === "[") {
2459
+ if (depth === 0) start = i;
2460
+ depth++;
2461
+ } else if (ch === "}" || ch === "]") {
2462
+ depth--;
2463
+ if (depth === 0 && start !== -1) {
2464
+ const slice = trimmed.slice(start, i + 1);
2465
+ try {
2466
+ const obj = JSON.parse(slice);
2467
+ if (obj && typeof obj === "object") parsed.push(obj as CloudCodeRawChunk);
2468
+ } catch (e) {
2469
+ // Invalid JSON in this slice -> keep as leftover for retry
2470
+ errors.push(`json_parse_slice_failed: ${(e as Error).message} slice=${slice.slice(0, 120)}`);
2471
+ leftover = trimmed.slice(start);
2472
+ break;
2473
+ }
2474
+ start = -1;
2475
+ }
2476
+ }
2477
+ }
2478
+ if (depth !== 0 && start !== -1) {
2479
+ leftover = trimmed.slice(start);
2480
+ } else if (parsed.length === 0 && trimmed.length > 0 && leftover === "") {
2481
+ leftover = trimmed;
2482
+ }
2483
+ return { parsed, leftover };
2484
+ };
2485
+
2486
+ for (let li = 0; li < lines.length; li++) {
2487
+ const line = lines[li]!;
2488
+ const trimmedLine = line.trim();
2489
+
2490
+ if (trimmedLine.length === 0) {
2491
+ // Empty line marks end of SSE event -> flush
2492
+ if (jsonAccumulator.trim().length > 0) {
2493
+ const res = flushAccumulator();
2494
+ objects.push(...res.parsed);
2495
+ if (res.leftover) {
2496
+ jsonAccumulator = res.leftover;
2497
+ if (li === lines.length - 1 && res.parsed.length === 0) {
2498
+ break;
2499
+ }
2500
+ } else {
2501
+ jsonAccumulator = "";
2502
+ }
2503
+ }
2504
+ insideDataBlock = false;
2505
+ dataPrefixFound = false;
2506
+ continue;
2507
+ }
2508
+
2509
+ // [DONE] sentinel from intermediate OpenAI-compatible wrappers
2510
+ if (trimmedLine === "data: [DONE]" || trimmedLine === "[DONE]") {
2511
+ continue;
2512
+ }
2513
+
2514
+ let dataContent = "";
2515
+ if (trimmedLine.startsWith("data:")) {
2516
+ dataPrefixFound = true;
2517
+ insideDataBlock = true;
2518
+ dataContent = trimmedLine.slice(5).trimStart();
2519
+ if (dataContent.length === 0) {
2520
+ continue;
2521
+ }
2522
+ } else if (trimmedLine.startsWith("event:") || trimmedLine.startsWith("id:") || trimmedLine.startsWith(":")) {
2523
+ // SSE metadata lines — ignore but keep framing
2524
+ continue;
2525
+ } else if (insideDataBlock || !dataPrefixFound) {
2526
+ // Prefix-less line inside a data block (multi-line JSON) or plain ndjson
2527
+ dataContent = trimmedLine;
2528
+ }
2529
+
2530
+ if (dataContent.length > 0) {
2531
+ // Preserve embedded newlines inside escaped JSON strings
2532
+ if (jsonAccumulator.length > 0) jsonAccumulator += "\n";
2533
+ jsonAccumulator += dataContent;
2534
+ // Flush early when the accumulator looks complete and the next line starts
2535
+ // a new event or the buffer ends — tolerates missing blank separators.
2536
+ const looksComplete = (() => {
2537
+ let d = 0,
2538
+ ins = false,
2539
+ esc = false;
2540
+ for (let k = 0; k < jsonAccumulator.length; k++) {
2541
+ const c = jsonAccumulator[k]!;
2542
+ if (esc) {
2543
+ esc = false;
2544
+ continue;
2545
+ }
2546
+ if (c === "\\" && ins) {
2547
+ esc = true;
2548
+ continue;
2549
+ }
2550
+ if (c === '"') {
2551
+ ins = !ins;
2552
+ continue;
2553
+ }
2554
+ if (ins) continue;
2555
+ if (c === "{" || c === "[") d++;
2556
+ else if (c === "}" || c === "]") d--;
2557
+ }
2558
+ return d === 0 && !ins;
2559
+ })();
2560
+ const nextLine = lines[li + 1];
2561
+ const nextIsData = nextLine?.trim().startsWith("data:") ?? false;
2562
+ const nextEmpty = (nextLine?.trim().length ?? 1) === 0;
2563
+ if (looksComplete && (nextIsData || nextEmpty || li === lines.length - 1)) {
2564
+ const res = flushAccumulator();
2565
+ objects.push(...res.parsed);
2566
+ if (res.leftover) {
2567
+ jsonAccumulator = res.leftover;
2568
+ } else {
2569
+ jsonAccumulator = "";
2570
+ insideDataBlock = false;
2571
+ }
2572
+ }
2573
+ }
2574
+ }
2575
+
2576
+ if (jsonAccumulator.trim().length > 0) {
2577
+ const res = flushAccumulator();
2578
+ if (res.parsed.length > 0) {
2579
+ objects.push(...res.parsed);
2580
+ remainder = res.leftover;
2581
+ } else {
2582
+ remainder = jsonAccumulator;
2583
+ }
2584
+ } else {
2585
+ remainder = "";
2586
+ }
2587
+
2588
+ return { objects, remainder, errors };
2589
+ }
2590
+
2591
+ /**
2592
+ * Remainder-based SSE parser for cloudcode-pa streams. Pure function: feed each
2593
+ * network chunk together with the previous remainder; it returns fully parsed
2594
+ * payloads plus the new remainder. Tolerates UTF-8 splits, concatenated JSON,
2595
+ * ndjson without SSE framing and `[DONE]` sentinels.
2596
+ *
2597
+ * Named `parseSSEPayloads` because {@link parseSSEChunk} already owns the
2598
+ * event-block parser name in this module.
2599
+ *
2600
+ * @param chunk - new raw text or binary chunk
2601
+ * @param previousRemainder - leftover text from the previous call
2602
+ * @returns parsed payloads, new remainder, error notes and consumed length
2603
+ *
2604
+ * @example
2605
+ * let remainder = "";
2606
+ * for await (const raw of stream) {
2607
+ * const { payloads, remainder: next } = parseSSEPayloads(raw, remainder);
2608
+ * remainder = next;
2609
+ * for (const p of payloads) handle(p);
2610
+ * }
2611
+ */
2612
+ export function parseSSEPayloads(
2613
+ chunk: string | Uint8Array,
2614
+ previousRemainder = "",
2615
+ ): { payloads: CloudCodeRawChunk[]; remainder: string; errors: string[]; consumed: number } {
2616
+ let text = "";
2617
+ if (typeof chunk === "string") {
2618
+ text = chunk;
2619
+ } else {
2620
+ try {
2621
+ text = Buffer.from(chunk).toString("utf8");
2622
+ } catch {
2623
+ text = new TextDecoder("utf-8", { fatal: false }).decode(chunk);
2624
+ }
2625
+ }
2626
+
2627
+ const combined = previousRemainder + text;
2628
+ const { objects, remainder, errors } = safeJsonParseArray(combined);
2629
+
2630
+ // Unwrap an outer { response: {...} } layer when present
2631
+ const payloads = objects.map((obj) => {
2632
+ const maybe = (obj as any).response as CloudCodeRawChunk | undefined;
2633
+ if (
2634
+ maybe &&
2635
+ typeof maybe === "object" &&
2636
+ ((maybe as any).candidates || (maybe as any).content || (maybe as any).parts)
2637
+ ) {
2638
+ return maybe;
2639
+ }
2640
+ return obj;
2641
+ });
2642
+
2643
+ return {
2644
+ payloads,
2645
+ remainder,
2646
+ errors,
2647
+ consumed: text.length,
2648
+ };
2649
+ }
2650
+
2651
+ // ---------------------------------------------------------------------------
2652
+ // Merged: normalizePayloads over typed raw chunks
2653
+ // ---------------------------------------------------------------------------
2654
+
2655
+ function normalizeFinishReason(raw: string): string | undefined {
2656
+ if (!raw) return undefined;
2657
+ const upper = raw.toUpperCase();
2658
+ if (upper.includes("STOP") || upper === "FINISH_REASON_STOP" || upper === "END") return "stop";
2659
+ if (upper.includes("MAX_TOKENS") || upper.includes("LENGTH")) return "length";
2660
+ if (upper.includes("SAFETY") || upper.includes("BLOCK")) return "content_filter";
2661
+ if (upper.includes("RECITATION")) return "content_filter";
2662
+ if (upper.includes("TOOL")) return "tool_calls";
2663
+ if (upper.includes("FINISH_REASON_UNSPECIFIED")) return undefined;
2664
+ return upper.toLowerCase();
2665
+ }
2666
+
2667
+ function extractTextFromAny(obj: unknown): string | undefined {
2668
+ if (!obj) return undefined;
2669
+ if (typeof obj === "string") return obj;
2670
+ if (typeof obj !== "object") return undefined;
2671
+ const o = obj as Record<string, unknown>;
2672
+ if (typeof o.text === "string") return o.text;
2673
+ if (typeof o.content === "string") return o.content;
2674
+ if (typeof o.message === "string") return o.message;
2675
+ if (Array.isArray(o.parts)) {
2676
+ const txt = (o.parts as any[]).map((p) => (typeof p === "string" ? p : ((p as any)?.text ?? ""))).join("");
2677
+ if (txt) return txt;
2678
+ }
2679
+ return undefined;
2680
+ }
2681
+
2682
+ /**
2683
+ * Normalizes heterogeneous raw chunks into {@link NormalizedPayload} arrays:
2684
+ * unwraps `{response:{...}}`, builds virtual candidates for content/parts/text
2685
+ * shapes, classifies parts into text/thinking/function_call/inline_data/
2686
+ * thought_signature kinds and carries finish reasons and usage metadata.
2687
+ *
2688
+ * Named `normalizeRawPayloads` because the string-based {@link normalizePayloads}
2689
+ * keeps its historical name in this module.
2690
+ *
2691
+ * @param rawChunks - typed raw chunks (output of parseSSEPayloads)
2692
+ * @param opts - model fallback and candidate index fallback
2693
+ * @returns normalized payloads
2694
+ */
2695
+ export function normalizeRawPayloads(
2696
+ rawChunks: CloudCodeRawChunk[],
2697
+ opts?: {
2698
+ model?: string;
2699
+ candidateIndexFallback?: number;
2700
+ },
2701
+ ): NormalizedPayload[] {
2702
+ const modelFallback = opts?.model ?? UA_DEFAULT_MODEL;
2703
+ const out: NormalizedPayload[] = [];
2704
+
2705
+ let globalIndex = opts?.candidateIndexFallback ?? 0;
2706
+
2707
+ for (let rawIndex = 0; rawIndex < rawChunks.length; rawIndex++) {
2708
+ const raw = rawChunks[rawIndex]!;
2709
+ if (!raw || typeof raw !== "object") continue;
2710
+
2711
+ // Guarantee unwrapping even if the parser layer was bypassed
2712
+ const unwrapped: CloudCodeRawChunk =
2713
+ (raw as any).response && typeof (raw as any).response === "object"
2714
+ ? ((raw as any).response as CloudCodeRawChunk)
2715
+ : raw;
2716
+
2717
+ const candidatesList: RawCandidate[] = [];
2718
+
2719
+ if (Array.isArray(unwrapped.candidates) && unwrapped.candidates.length > 0) {
2720
+ candidatesList.push(...unwrapped.candidates);
2721
+ } else if (unwrapped.content) {
2722
+ candidatesList.push({ content: unwrapped.content, index: 0 });
2723
+ } else if (Array.isArray(unwrapped.parts) && unwrapped.parts.length > 0) {
2724
+ candidatesList.push({ content: { parts: unwrapped.parts }, index: 0 });
2725
+ } else if (typeof (unwrapped as any).text === "string") {
2726
+ candidatesList.push({ content: { parts: [{ text: (unwrapped as any).text as string }] }, index: 0 });
2727
+ } else if (typeof (unwrapped as any).thought === "string") {
2728
+ candidatesList.push({
2729
+ content: {
2730
+ parts: [
2731
+ {
2732
+ text: (unwrapped as any).thought as string,
2733
+ thought: true,
2734
+ thoughtSignature: (unwrapped as any).thoughtSignature as string | undefined,
2735
+ } as RawPart,
2736
+ ],
2737
+ },
2738
+ index: 0,
2739
+ });
2740
+ } else {
2741
+ const txt = extractTextFromAny(unwrapped);
2742
+ if (txt) {
2743
+ candidatesList.push({ content: { parts: [{ text: txt }] }, index: 0 });
2744
+ } else {
2745
+ continue;
2746
+ }
2747
+ }
2748
+
2749
+ for (let ci = 0; ci < candidatesList.length; ci++) {
2750
+ const cand = candidatesList[ci]!;
2751
+ const candIndex = typeof cand.index === "number" ? cand.index : ci;
2752
+ const finishReasonRaw = (cand.finishReason || (unwrapped as any).finishReason || "") as string;
2753
+ const finishReason = normalizeFinishReason(finishReasonRaw);
2754
+
2755
+ let rawParts: RawPart[] = [];
2756
+
2757
+ if (cand.content?.parts && Array.isArray(cand.content.parts)) {
2758
+ rawParts = cand.content.parts;
2759
+ } else if ((cand as any).parts && Array.isArray((cand as any).parts)) {
2760
+ rawParts = (cand as any).parts;
2761
+ } else if (typeof cand.text === "string" && cand.text.length > 0) {
2762
+ rawParts = [
2763
+ { text: cand.text, thought: !!(cand as any).thought, thoughtSignature: (cand as any).thoughtSignature },
2764
+ ];
2765
+ } else if (cand.content?.text && typeof cand.content.text === "string") {
2766
+ rawParts = [{ text: cand.content.text }];
2767
+ } else if (typeof (cand.content as any)?.text === "string") {
2768
+ rawParts = [{ text: (cand.content as any).text as string }];
2769
+ }
2770
+
2771
+ const normalizedParts: NormalizedPart[] = [];
2772
+ let isThinkingChunk = false;
2773
+
2774
+ for (let pi = 0; pi < rawParts.length; pi++) {
2775
+ const rp = rawParts[pi]!;
2776
+ if (!rp || typeof rp !== "object") continue;
2777
+
2778
+ const txt = typeof rp.text === "string" ? rp.text : extractTextFromAny(rp);
2779
+ const isThought = rp.thought === true || (rp as any).isThought === true;
2780
+ const sig =
2781
+ typeof rp.thoughtSignature === "string"
2782
+ ? rp.thoughtSignature
2783
+ : typeof (rp as any).thought_signature === "string"
2784
+ ? ((rp as any).thought_signature as string)
2785
+ : undefined;
2786
+
2787
+ if (isThought) isThinkingChunk = true;
2788
+
2789
+ if (rp.functionCall && typeof rp.functionCall === "object") {
2790
+ const fc = rp.functionCall as { name: string; args?: unknown; id?: string };
2791
+ const argsStr = typeof fc.args === "string" ? fc.args : JSON.stringify(fc.args ?? {});
2792
+ normalizedParts.push({
2793
+ kind: "function_call",
2794
+ text: "",
2795
+ thought: false,
2796
+ thoughtSignature: sig,
2797
+ functionCall: {
2798
+ name: fc.name || "unknown",
2799
+ args: argsStr,
2800
+ id: fc.id || `call_${globalIndex}_${ci}_${pi}_${Date.now().toString(36)}`,
2801
+ },
2802
+ rawIndex,
2803
+ candidateIndex: candIndex,
2804
+ finishReason,
2805
+ });
2806
+ continue;
2807
+ }
2808
+
2809
+ if (rp.inlineData) {
2810
+ normalizedParts.push({
2811
+ kind: "inline_data",
2812
+ text: "",
2813
+ thought: false,
2814
+ thoughtSignature: sig,
2815
+ rawIndex,
2816
+ candidateIndex: candIndex,
2817
+ finishReason,
2818
+ });
2819
+ continue;
2820
+ }
2821
+
2822
+ if (!txt && sig) {
2823
+ normalizedParts.push({
2824
+ kind: "thought_signature",
2825
+ text: "",
2826
+ thought: true,
2827
+ thoughtSignature: sig,
2828
+ rawIndex,
2829
+ candidateIndex: candIndex,
2830
+ finishReason,
2831
+ });
2832
+ continue;
2833
+ }
2834
+
2835
+ if (!txt && !sig) continue;
2836
+
2837
+ normalizedParts.push({
2838
+ kind: isThought ? "thinking" : "text",
2839
+ text: txt ?? "",
2840
+ thought: isThought,
2841
+ thoughtSignature: sig,
2842
+ rawIndex,
2843
+ candidateIndex: candIndex,
2844
+ finishReason,
2845
+ });
2846
+ }
2847
+
2848
+ const hasFinish = !!finishReason;
2849
+
2850
+ if (normalizedParts.length === 0 && !hasFinish) {
2851
+ // Usage-only chunk
2852
+ if (unwrapped.usageMetadata) {
2853
+ out.push({
2854
+ parts: [],
2855
+ finishReason: undefined,
2856
+ usage: unwrapped.usageMetadata,
2857
+ model: unwrapped.model ?? modelFallback,
2858
+ isThinkingChunk: false,
2859
+ hasFinish: false,
2860
+ raw: unwrapped,
2861
+ });
2862
+ }
2863
+ continue;
2864
+ }
2865
+
2866
+ out.push({
2867
+ parts: normalizedParts,
2868
+ finishReason: finishReason || undefined,
2869
+ usage: unwrapped.usageMetadata,
2870
+ model: unwrapped.model ?? modelFallback,
2871
+ isThinkingChunk,
2872
+ hasFinish,
2873
+ raw: unwrapped,
2874
+ });
2875
+
2876
+ globalIndex++;
2877
+ }
2878
+ }
2879
+
2880
+ return out;
2881
+ }
2882
+
2883
+ // ---------------------------------------------------------------------------
2884
+ // Merged: stateful OpenAI / Anthropic transforms over normalized payloads
2885
+ // ---------------------------------------------------------------------------
2886
+
2887
+ function newCompletionId(): string {
2888
+ try {
2889
+ const uuid = randomUUID();
2890
+ return `chatcmpl-${uuid.replace(/-/g, "").slice(0, 29)}`;
2891
+ } catch {
2892
+ return `chatcmpl-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
2893
+ }
2894
+ }
2895
+
2896
+ /**
2897
+ * Creates the mutable state shared by the merged transformers.
2898
+ *
2899
+ * @param model - model id echoed back to clients
2900
+ * @param completionId - optional fixed completion id
2901
+ */
2902
+ export function createInitialStreamingState(model: string, completionId?: string): StreamingState {
2903
+ return {
2904
+ completionId: completionId ?? newCompletionId(),
2905
+ model,
2906
+ created: Math.floor(Date.now() / 1000),
2907
+ sentRole: false,
2908
+ accumulatedText: "",
2909
+ accumulatedThinking: "",
2910
+ lastFinishReason: undefined,
2911
+ insideThinking: false,
2912
+ anthropicBlockIndex: 0,
2913
+ anthropicBlockKinds: new Map(),
2914
+ toolCallIdCounter: 0,
2915
+ pendingSignatures: [],
2916
+ };
2917
+ }
2918
+
2919
+ /**
2920
+ * Transforms normalized payloads into OpenAI chat.completion.chunk objects.
2921
+ * Emits a leading role chunk, routes thinking into reasoning_content/reasoning,
2922
+ * caches thought signatures in the global entry-based LRU and streams tool calls.
2923
+ *
2924
+ * @param payloads - normalized payloads
2925
+ * @param state - mutable streaming state updated in place
2926
+ * @returns OpenAI chunk objects (serialize with serializeOpenAIChunksToSSE)
2927
+ */
2928
+ export function transformToOpenAIChunks(payloads: NormalizedPayload[], state: StreamingState): OpenAIChatChunk[] {
2929
+ const out: OpenAIChatChunk[] = [];
2930
+
2931
+ for (const p of payloads) {
2932
+ if (p.finishReason) state.lastFinishReason = p.finishReason;
2933
+
2934
+ if (p.parts.length === 0 && p.usage) {
2935
+ continue;
2936
+ }
2937
+
2938
+ for (const part of p.parts) {
2939
+ if (!state.sentRole) {
2940
+ out.push({
2941
+ id: state.completionId,
2942
+ object: "chat.completion.chunk",
2943
+ created: state.created,
2944
+ model: state.model,
2945
+ choices: [
2946
+ {
2947
+ index: part.candidateIndex,
2948
+ delta: { role: "assistant" },
2949
+ finish_reason: null,
2950
+ },
2951
+ ],
2952
+ });
2953
+ state.sentRole = true;
2954
+ }
2955
+
2956
+ if (part.thoughtSignature) {
2957
+ try {
2958
+ globalThinkingSignatureLRUCache.addFromPayload({
2959
+ signature: part.thoughtSignature,
2960
+ thinkingText: part.kind === "thinking" ? part.text : state.accumulatedThinking.slice(-2000),
2961
+ model: p.model ?? state.model,
2962
+ turnId: `${state.completionId}_${part.candidateIndex}`,
2963
+ });
2964
+ } catch {
2965
+ // Cache failures must never break streaming
2966
+ }
2967
+ if (!state.pendingSignatures.includes(part.thoughtSignature)) {
2968
+ state.pendingSignatures.push(part.thoughtSignature);
2969
+ if (state.pendingSignatures.length > 50) state.pendingSignatures.shift();
2970
+ }
2971
+ }
2972
+
2973
+ if (part.kind === "function_call" && part.functionCall) {
2974
+ out.push({
2975
+ id: state.completionId,
2976
+ object: "chat.completion.chunk",
2977
+ created: state.created,
2978
+ model: state.model,
2979
+ choices: [
2980
+ {
2981
+ index: part.candidateIndex,
2982
+ delta: {
2983
+ tool_calls: [
2984
+ {
2985
+ index: state.toolCallIdCounter++,
2986
+ id: part.functionCall.id,
2987
+ type: "function",
2988
+ function: {
2989
+ name: part.functionCall.name,
2990
+ arguments: part.functionCall.args,
2991
+ },
2992
+ },
2993
+ ],
2994
+ },
2995
+ finish_reason: null,
2996
+ },
2997
+ ],
2998
+ });
2999
+ continue;
3000
+ }
3001
+
3002
+ if (part.kind === "thinking" || part.thought) {
3003
+ if (!state.insideThinking) {
3004
+ state.insideThinking = true;
3005
+ }
3006
+ state.accumulatedThinking += part.text;
3007
+
3008
+ out.push({
3009
+ id: state.completionId,
3010
+ object: "chat.completion.chunk",
3011
+ created: state.created,
3012
+ model: state.model,
3013
+ choices: [
3014
+ {
3015
+ index: part.candidateIndex,
3016
+ delta: {
3017
+ reasoning_content: part.text,
3018
+ reasoning: part.text,
3019
+ },
3020
+ finish_reason: null,
3021
+ },
3022
+ ],
3023
+ });
3024
+ } else if (part.kind === "text") {
3025
+ if (state.insideThinking) {
3026
+ state.insideThinking = false;
3027
+ }
3028
+ state.accumulatedText += part.text;
3029
+
3030
+ out.push({
3031
+ id: state.completionId,
3032
+ object: "chat.completion.chunk",
3033
+ created: state.created,
3034
+ model: state.model,
3035
+ choices: [
3036
+ {
3037
+ index: part.candidateIndex,
3038
+ delta: {
3039
+ content: part.text,
3040
+ },
3041
+ finish_reason: null,
3042
+ },
3043
+ ],
3044
+ });
3045
+ } else if (part.kind === "thought_signature") {
3046
+ continue;
3047
+ }
3048
+ }
3049
+
3050
+ if (p.hasFinish && p.finishReason) {
3051
+ // Finish reason recorded; final emission handled by synthetic finish helpers
3052
+ }
3053
+ }
3054
+
3055
+ return out;
3056
+ }
3057
+
3058
+ /**
3059
+ * Builds the final OpenAI finish chunk (with optional usage mapping) once the
3060
+ * stream ends. The caller serializes it and appends the `[DONE]` marker.
3061
+ *
3062
+ * @param state - streaming state carrying the last observed finish reason
3063
+ * @param reason - fallback finish reason
3064
+ * @param usage - usage metadata captured from the stream
3065
+ */
3066
+ export function createSyntheticFinishChunks(
3067
+ state: StreamingState,
3068
+ reason: string = "stop",
3069
+ usage?: NormalizedPayload["usage"],
3070
+ ): OpenAIChatChunk[] {
3071
+ const finish = state.lastFinishReason ?? reason;
3072
+ const normalized = normalizeFinishReason(finish) ?? "stop";
3073
+
3074
+ const chunk: OpenAIChatChunk = {
3075
+ id: state.completionId,
3076
+ object: "chat.completion.chunk",
3077
+ created: state.created,
3078
+ model: state.model,
3079
+ choices: [
3080
+ {
3081
+ index: 0,
3082
+ delta: {},
3083
+ finish_reason: normalized,
3084
+ },
3085
+ ],
3086
+ };
3087
+
3088
+ if (usage) {
3089
+ (chunk as any).usage = {
3090
+ prompt_tokens: usage.promptTokenCount,
3091
+ completion_tokens: usage.candidatesTokenCount ?? usage.totalTokenCount,
3092
+ total_tokens: usage.totalTokenCount,
3093
+ completion_tokens_details: usage.thoughtsTokenCount ? { reasoning_tokens: usage.thoughtsTokenCount } : undefined,
3094
+ };
3095
+ }
3096
+
3097
+ return [chunk];
3098
+ }
3099
+
3100
+ /**
3101
+ * Serializes OpenAI chunks into SSE data lines ready for the wire.
3102
+ *
3103
+ * @param chunks - OpenAI chunk objects
3104
+ * @returns array of `data: {...}\n\n` strings
3105
+ */
3106
+ export function serializeOpenAIChunksToSSE(chunks: OpenAIChatChunk[]): string[] {
3107
+ const lines: string[] = [];
3108
+ for (const c of chunks) {
3109
+ lines.push(`data: ${JSON.stringify(c)}\n\n`);
3110
+ }
3111
+ return lines;
3112
+ }
3113
+
3114
+ /**
3115
+ * Transforms normalized payloads into Anthropic Messages SSE lines:
3116
+ * message_start, content_block_start/delta/stop and message_delta/stop.
3117
+ * Opens separate blocks when thinking interleaves with text so block ordering
3118
+ * satisfies Claude validation.
3119
+ *
3120
+ * @param payloads - normalized payloads
3121
+ * @param state - mutable streaming state updated in place
3122
+ * @returns formatted SSE strings (`event: ...\ndata: {...}\n\n`)
3123
+ */
3124
+ export function transformToAnthropicEvents(payloads: NormalizedPayload[], state: StreamingState): string[] {
3125
+ const sseLines: string[] = [];
3126
+
3127
+ const emit = (event: string, data: unknown): void => {
3128
+ sseLines.push(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
3129
+ };
3130
+
3131
+ if (state.anthropicBlockIndex === 0 && !state.sentRole) {
3132
+ emit("message_start", {
3133
+ type: "message_start",
3134
+ message: {
3135
+ id: state.completionId.replace("chatcmpl-", "msg_"),
3136
+ type: "message",
3137
+ role: "assistant",
3138
+ content: [],
3139
+ model: state.model,
3140
+ stop_reason: null,
3141
+ stop_sequence: null,
3142
+ usage: { input_tokens: 0, output_tokens: 0 },
3143
+ },
3144
+ });
3145
+ state.sentRole = true;
3146
+ }
3147
+
3148
+ for (const p of payloads) {
3149
+ if (p.finishReason) state.lastFinishReason = p.finishReason;
3150
+
3151
+ for (const part of p.parts) {
3152
+ if (part.thoughtSignature) {
3153
+ try {
3154
+ globalThinkingSignatureLRUCache.addFromPayload({
3155
+ signature: part.thoughtSignature,
3156
+ thinkingText: part.text || state.accumulatedThinking.slice(-2000),
3157
+ model: p.model ?? state.model,
3158
+ turnId: `${state.completionId}_${part.candidateIndex}`,
3159
+ });
3160
+ } catch {
3161
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3162
+ }
3163
+ }
3164
+
3165
+ if (part.kind === "function_call" && part.functionCall) {
3166
+ const blockIndex = state.anthropicBlockIndex++;
3167
+ state.anthropicBlockKinds.set(blockIndex, "function_call");
3168
+
3169
+ emit("content_block_start", {
3170
+ type: "content_block_start",
3171
+ index: blockIndex,
3172
+ content_block: {
3173
+ type: "tool_use",
3174
+ id: part.functionCall.id,
3175
+ name: part.functionCall.name,
3176
+ input: {},
3177
+ },
3178
+ });
3179
+
3180
+ emit("content_block_delta", {
3181
+ type: "content_block_delta",
3182
+ index: blockIndex,
3183
+ delta: {
3184
+ type: "input_json_delta",
3185
+ partial_json: part.functionCall.args,
3186
+ },
3187
+ });
3188
+
3189
+ emit("content_block_stop", {
3190
+ type: "content_block_stop",
3191
+ index: blockIndex,
3192
+ });
3193
+ continue;
3194
+ }
3195
+
3196
+ const isThinking = part.kind === "thinking" || part.thought;
3197
+ const wantedKind: NormalizedPartKind = isThinking ? "thinking" : "text";
3198
+
3199
+ let currentBlockIndex = state.anthropicBlockIndex;
3200
+ const currentKind = state.anthropicBlockKinds.get(currentBlockIndex - 1);
3201
+
3202
+ const needNewBlock =
3203
+ currentBlockIndex === 0 ||
3204
+ (currentKind !== undefined && currentKind !== wantedKind && currentKind !== part.kind) ||
3205
+ state.insideThinking !== isThinking;
3206
+
3207
+ if (needNewBlock) {
3208
+ if (currentBlockIndex > 0 && currentKind !== undefined) {
3209
+ if (currentKind !== wantedKind) {
3210
+ emit("content_block_stop", {
3211
+ type: "content_block_stop",
3212
+ index: currentBlockIndex - 1,
3213
+ });
3214
+ }
3215
+ }
3216
+
3217
+ if (currentKind !== wantedKind || currentBlockIndex === 0) {
3218
+ const idx = state.anthropicBlockIndex;
3219
+ state.anthropicBlockKinds.set(idx, wantedKind);
3220
+ state.anthropicBlockIndex++;
3221
+
3222
+ if (isThinking) {
3223
+ emit("content_block_start", {
3224
+ type: "content_block_start",
3225
+ index: idx,
3226
+ content_block: { type: "thinking", thinking: "" },
3227
+ });
3228
+ state.insideThinking = true;
3229
+ } else {
3230
+ emit("content_block_start", {
3231
+ type: "content_block_start",
3232
+ index: idx,
3233
+ content_block: { type: "text", text: "" },
3234
+ });
3235
+ state.insideThinking = false;
3236
+ }
3237
+ currentBlockIndex = idx;
3238
+ }
3239
+ }
3240
+
3241
+ if (isThinking) {
3242
+ state.accumulatedThinking += part.text;
3243
+ const idx = state.anthropicBlockIndex - 1;
3244
+ emit("content_block_delta", {
3245
+ type: "content_block_delta",
3246
+ index: idx,
3247
+ delta: { type: "thinking_delta", thinking: part.text },
3248
+ });
3249
+ } else if (part.kind === "text") {
3250
+ state.accumulatedText += part.text;
3251
+ const idx = state.anthropicBlockIndex - 1;
3252
+ emit("content_block_delta", {
3253
+ type: "content_block_delta",
3254
+ index: idx,
3255
+ delta: { type: "text_delta", text: part.text },
3256
+ });
3257
+ }
3258
+ }
3259
+ }
3260
+
3261
+ return sseLines;
3262
+ }
3263
+
3264
+ /**
3265
+ * Emits the final Anthropic events (block stop, message_delta with stop_reason,
3266
+ * message_stop) once the stream ends.
3267
+ *
3268
+ * @param state - streaming state
3269
+ * @param reason - fallback stop reason
3270
+ * @param usage - usage metadata captured from the stream
3271
+ * @returns formatted SSE strings
3272
+ */
3273
+ export function createAnthropicSyntheticFinish(
3274
+ state: StreamingState,
3275
+ reason: string = "end_turn",
3276
+ usage?: NormalizedPayload["usage"],
3277
+ ): string[] {
3278
+ const lines: string[] = [];
3279
+
3280
+ const emit = (event: string, data: unknown) => {
3281
+ lines.push(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
3282
+ };
3283
+
3284
+ if (state.anthropicBlockIndex > 0) {
3285
+ const lastIdx = state.anthropicBlockIndex - 1;
3286
+ if (state.anthropicBlockKinds.has(lastIdx)) {
3287
+ emit("content_block_stop", { type: "content_block_stop", index: lastIdx });
3288
+ }
3289
+ }
3290
+
3291
+ emit("message_delta", {
3292
+ type: "message_delta",
3293
+ delta: {
3294
+ stop_reason: state.lastFinishReason ? mapFinishToAnthropic(state.lastFinishReason) : reason,
3295
+ stop_sequence: null,
3296
+ },
3297
+ usage: usage
3298
+ ? {
3299
+ output_tokens: usage.candidatesTokenCount ?? usage.totalTokenCount ?? 0,
3300
+ }
3301
+ : { output_tokens: 0 },
3302
+ });
3303
+
3304
+ emit("message_stop", { type: "message_stop" });
3305
+
3306
+ return lines;
3307
+ }
3308
+
3309
+ function mapFinishToAnthropic(finish: string): string {
3310
+ const n = finish.toLowerCase();
3311
+ if (n === "stop" || n.includes("stop")) return "end_turn";
3312
+ if (n === "length" || n.includes("max")) return "max_tokens";
3313
+ if (n === "tool_calls") return "tool_use";
3314
+ if (n === "content_filter") return "end_turn";
3315
+ return "end_turn";
3316
+ }
3317
+
3318
+ // ---------------------------------------------------------------------------
3319
+ // Merged: incremental SSE parser class + high-level transformer
3320
+ // ---------------------------------------------------------------------------
3321
+
3322
+ /**
3323
+ * Stateful incremental SSE parser wrapping parseSSEPayloads with streaming
3324
+ * UTF-8 decoding and byte accounting.
3325
+ */
3326
+ export class IncrementalSSEParser {
3327
+ private buffer: string;
3328
+ private readonly decoder: { decode(input?: Uint8Array, options?: { stream?: boolean }): string };
3329
+ private totalBytes: number;
3330
+
3331
+ constructor() {
3332
+ this.buffer = "";
3333
+ this.decoder = new TextDecoder("utf-8", { fatal: false });
3334
+ this.totalBytes = 0;
3335
+ }
3336
+
3337
+ /**
3338
+ * Pushes an incremental chunk (string, Uint8Array or Buffer), tolerating
3339
+ * splits anywhere, and returns payloads parsed so far.
3340
+ */
3341
+ push(chunk: string | Uint8Array | Buffer): CloudCodeRawChunk[] {
3342
+ let text: string;
3343
+ if (typeof chunk === "string") {
3344
+ text = chunk;
3345
+ } else if (chunk instanceof Uint8Array || Buffer.isBuffer(chunk)) {
3346
+ text = this.decoder.decode(chunk, { stream: true });
3347
+ } else {
3348
+ text = String(chunk);
3349
+ }
3350
+
3351
+ this.totalBytes += text.length;
3352
+ const result = parseSSEPayloads(text, this.buffer);
3353
+ this.buffer = result.remainder;
3354
+ return result.payloads;
3355
+ }
3356
+
3357
+ /**
3358
+ * Final flush: decodes any pending bytes and attempts to parse the remainder.
3359
+ */
3360
+ flush(): CloudCodeRawChunk[] {
3361
+ if (!this.buffer.trim()) {
3362
+ this.buffer = "";
3363
+ return [];
3364
+ }
3365
+ try {
3366
+ const finalText = this.decoder.decode();
3367
+ if (finalText) this.buffer += finalText;
3368
+ } catch {
3369
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3370
+ }
3371
+ const result = parseSSEPayloads("", this.buffer);
3372
+ this.buffer = result.remainder;
3373
+ if (result.payloads.length > 0) {
3374
+ this.buffer = "";
3375
+ }
3376
+ return result.payloads;
3377
+ }
3378
+
3379
+ get remainder(): string {
3380
+ return this.buffer;
3381
+ }
3382
+
3383
+ get bytesReceived(): number {
3384
+ return this.totalBytes;
3385
+ }
3386
+
3387
+ reset(): void {
3388
+ this.buffer = "";
3389
+ this.totalBytes = 0;
3390
+ }
3391
+ }
3392
+
3393
+ /** Output format selector for {@link CloudCodeStreamingTransformer}. */
3394
+ export type OutputFormat = "openai" | "anthropic" | "raw";
3395
+
3396
+ /**
3397
+ * Options for the high-level streaming transformer.
3398
+ */
3399
+ export interface StreamingTransformerOptions {
3400
+ /** Target model id. */
3401
+ model: string;
3402
+ /** Wire format, openai by default. */
3403
+ format?: OutputFormat;
3404
+ /** Optional fixed completion id. */
3405
+ completionId?: string;
3406
+ /** External entry-based LRU cache (defaults to the global singleton). */
3407
+ thinkingCache?: ThinkingSignatureLRUCache;
3408
+ /** Inject a synthetic finish when the stream ends without one? Default true. */
3409
+ injectSyntheticFinish?: boolean;
3410
+ /** Append `[DONE]` after OpenAI SSE? Default true. */
3411
+ includeDoneMarker?: boolean;
3412
+ /** Callback invoked for every transformed SSE line. */
3413
+ onChunk?: (sseLine: string, state: StreamingState) => void;
3414
+ }
3415
+
3416
+ /**
3417
+ * High-level cloudcode-pa SSE transformer: push raw network chunks in, receive
3418
+ * serialized SSE lines out in the configured format, with synthetic finish
3419
+ * injection and `[DONE]` handling on flush.
3420
+ */
3421
+ export class CloudCodeStreamingTransformer {
3422
+ private readonly parser: IncrementalSSEParser;
3423
+ private readonly state: StreamingState;
3424
+ private readonly options: Required<StreamingTransformerOptions>;
3425
+ private sentFinished: boolean;
3426
+ private lastUsage?: CloudCodeRawChunk["usageMetadata"];
3427
+
3428
+ constructor(opts: StreamingTransformerOptions) {
3429
+ this.parser = new IncrementalSSEParser();
3430
+ this.state = createInitialStreamingState(opts.model, opts.completionId);
3431
+ this.options = {
3432
+ model: opts.model,
3433
+ format: opts.format ?? "openai",
3434
+ completionId: this.state.completionId,
3435
+ thinkingCache: opts.thinkingCache ?? globalThinkingSignatureLRUCache,
3436
+ injectSyntheticFinish: opts.injectSyntheticFinish ?? true,
3437
+ includeDoneMarker: opts.includeDoneMarker ?? true,
3438
+ onChunk: opts.onChunk ?? (() => {}),
3439
+ } as Required<StreamingTransformerOptions>;
3440
+ this.sentFinished = false;
3441
+ }
3442
+
3443
+ get streamingState(): StreamingState {
3444
+ return this.state;
3445
+ }
3446
+
3447
+ /**
3448
+ * Transforms one raw chunk into output SSE lines.
3449
+ */
3450
+ transformChunk(rawChunk: string | Uint8Array): string[] {
3451
+ const parsed = this.parser.push(rawChunk);
3452
+ if (parsed.length === 0) return [];
3453
+
3454
+ for (const r of parsed) {
3455
+ if ((r as any).usageMetadata) this.lastUsage = (r as any).usageMetadata;
3456
+ }
3457
+
3458
+ const normalized = normalizeRawPayloads(parsed, { model: this.options.model });
3459
+
3460
+ let sseLines: string[] = [];
3461
+
3462
+ if (this.options.format === "openai") {
3463
+ const openAIChunks = transformToOpenAIChunks(normalized, this.state);
3464
+ sseLines = serializeOpenAIChunksToSSE(openAIChunks);
3465
+ } else if (this.options.format === "anthropic") {
3466
+ sseLines = transformToAnthropicEvents(normalized, this.state);
3467
+ } else {
3468
+ for (const n of normalized) {
3469
+ sseLines.push(`data: ${JSON.stringify(n)}\n\n`);
3470
+ }
3471
+ }
3472
+
3473
+ for (const line of sseLines) {
3474
+ try {
3475
+ this.options.onChunk(line, this.state);
3476
+ } catch {
3477
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3478
+ }
3479
+ }
3480
+
3481
+ return sseLines;
3482
+ }
3483
+
3484
+ /**
3485
+ * Flushes buffered input and injects the synthetic finish plus `[DONE]`.
3486
+ */
3487
+ flush(): string[] {
3488
+ const remainingParsed = this.parser.flush();
3489
+ const extraLines: string[] = [];
3490
+
3491
+ if (remainingParsed.length > 0) {
3492
+ for (const r of remainingParsed) {
3493
+ if ((r as any).usageMetadata) this.lastUsage = (r as any).usageMetadata;
3494
+ }
3495
+ const normalized = normalizeRawPayloads(remainingParsed, { model: this.options.model });
3496
+ if (this.options.format === "openai") {
3497
+ const chunks = transformToOpenAIChunks(normalized, this.state);
3498
+ extraLines.push(...serializeOpenAIChunksToSSE(chunks));
3499
+ } else if (this.options.format === "anthropic") {
3500
+ extraLines.push(...transformToAnthropicEvents(normalized, this.state));
3501
+ } else {
3502
+ for (const n of normalized) extraLines.push(`data: ${JSON.stringify(n)}\n\n`);
3503
+ }
3504
+ }
3505
+
3506
+ if (!this.sentFinished && this.options.injectSyntheticFinish) {
3507
+ const finishLines = this.createFinish();
3508
+ extraLines.push(...finishLines);
3509
+ this.sentFinished = true;
3510
+ }
3511
+
3512
+ if (this.options.format === "openai" && this.options.includeDoneMarker) {
3513
+ extraLines.push("data: [DONE]\n\n");
3514
+ }
3515
+
3516
+ for (const line of extraLines) {
3517
+ try {
3518
+ this.options.onChunk(line, this.state);
3519
+ } catch {
3520
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3521
+ }
3522
+ }
3523
+
3524
+ return extraLines;
3525
+ }
3526
+
3527
+ private createFinish(): string[] {
3528
+ if (this.options.format === "openai") {
3529
+ const chunks = createSyntheticFinishChunks(this.state, "stop", this.lastUsage);
3530
+ return serializeOpenAIChunksToSSE(chunks);
3531
+ } else if (this.options.format === "anthropic") {
3532
+ return createAnthropicSyntheticFinish(this.state, "end_turn", this.lastUsage);
3533
+ } else {
3534
+ return [
3535
+ `data: ${JSON.stringify({ finishReason: this.state.lastFinishReason ?? "stop", usage: this.lastUsage })}\n\n`,
3536
+ ];
3537
+ }
3538
+ }
3539
+
3540
+ reset(model?: string): void {
3541
+ this.parser.reset();
3542
+ this.sentFinished = false;
3543
+ this.lastUsage = undefined;
3544
+ this.state.accumulatedText = "";
3545
+ this.state.accumulatedThinking = "";
3546
+ this.state.sentRole = false;
3547
+ this.state.insideThinking = false;
3548
+ this.state.lastFinishReason = undefined;
3549
+ this.state.anthropicBlockIndex = 0;
3550
+ this.state.anthropicBlockKinds.clear();
3551
+ this.state.toolCallIdCounter = 0;
3552
+ this.state.pendingSignatures = [];
3553
+ if (model) {
3554
+ this.state.model = model;
3555
+ (this.options as any).model = model;
3556
+ }
3557
+ this.state.completionId = newCompletionId();
3558
+ this.state.created = Math.floor(Date.now() / 1000);
3559
+ }
3560
+ }
3561
+
3562
+ // ---------------------------------------------------------------------------
3563
+ // Merged: resilient fetch + transformed stream
3564
+ // ---------------------------------------------------------------------------
3565
+
3566
+ /**
3567
+ * Options for {@link resilientFetch}.
3568
+ */
3569
+ export interface ResilientFetchOptions {
3570
+ /** Max attempts including the first. */
3571
+ maxRetries?: number;
3572
+ /** Base delay in ms. */
3573
+ baseDelayMs?: number;
3574
+ /** Max delay in ms. */
3575
+ maxDelayMs?: number;
3576
+ /** Apply jitter? Default true. */
3577
+ jitter?: boolean;
3578
+ /** Status codes that trigger a retry. */
3579
+ retryOn?: number[];
3580
+ /** External abort signal. */
3581
+ signal?: AbortSignal;
3582
+ /** Per-attempt timeout in ms. */
3583
+ timeoutMs?: number;
3584
+ }
3585
+
3586
+ const DEFAULT_RETRY_ON = [429, 500, 502, 503, 504];
3587
+
3588
+ /**
3589
+ * Computes exponential delay with optional multiplicative jitter (0.7x-1.3x).
3590
+ *
3591
+ * @param attempt - zero-based attempt index
3592
+ * @param base - base delay ms
3593
+ * @param max - maximum delay ms
3594
+ * @param jitter - apply jitter
3595
+ */
3596
+ export function computeExponentialDelay(attempt: number, base: number, max: number, jitter: boolean): number {
3597
+ let delay = base * Math.pow(2, attempt);
3598
+ if (delay > max) delay = max;
3599
+ if (jitter) {
3600
+ const jitterFactor = 0.7 + Math.random() * 0.6;
3601
+ delay = Math.floor(delay * jitterFactor);
3602
+ }
3603
+ return delay;
3604
+ }
3605
+
3606
+ // sleepMs answers from @wenathlan/devthink/queue (the owner of the timer
3607
+ // family) — the former local twin retired into the published import.
3608
+
3609
+ /**
3610
+ * Fetches streamGenerateContent with exponential backoff, per-attempt timeout
3611
+ * and external-signal support, keeping Gemini CLI bypass headers intact.
3612
+ *
3613
+ * Equivalent sibling of {@link fetchWithExponentialRetry}; both are kept.
3614
+ *
3615
+ * @param url - endpoint URL
3616
+ * @param init - RequestInit for fetch
3617
+ * @param opts - retry options
3618
+ * @returns Response with body stream
3619
+ */
3620
+ export async function resilientFetch(
3621
+ url: string,
3622
+ init: RequestInit,
3623
+ opts: ResilientFetchOptions = {},
3624
+ ): Promise<Response> {
3625
+ const maxRetries = opts.maxRetries ?? 3;
3626
+ const baseDelay = opts.baseDelayMs ?? 500;
3627
+ const maxDelay = opts.maxDelayMs ?? 5000;
3628
+ const jitter = opts.jitter ?? true;
3629
+ const retryOn = opts.retryOn ?? DEFAULT_RETRY_ON;
3630
+
3631
+ let lastError: Error | null = null;
3632
+
3633
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
3634
+ const timeoutMs = opts.timeoutMs ?? 120_000;
3635
+ const ac = new AbortController();
3636
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
3637
+
3638
+ const onAbortExternal = () => ac.abort();
3639
+ if (opts.signal) {
3640
+ if (opts.signal.aborted) {
3641
+ clearTimeout(timer);
3642
+ throw new Error("resilientFetch aborted by external signal");
3643
+ }
3644
+ opts.signal.addEventListener("abort", onAbortExternal, { once: true });
3645
+ }
3646
+
3647
+ try {
3648
+ const mergedInit: RequestInit = {
3649
+ ...init,
3650
+ signal: ac.signal,
3651
+ };
3652
+
3653
+ const res = await fetch(url, mergedInit);
3654
+ clearTimeout(timer);
3655
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbortExternal);
3656
+
3657
+ if (res.ok) return res;
3658
+
3659
+ if (isRetryableNetworkStatus(res.status, { retryableStatus: retryOn }) && attempt < maxRetries) {
3660
+ lastError = new Error(`retryable status ${res.status}`);
3661
+ try {
3662
+ await res.text();
3663
+ } catch {
3664
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3665
+ }
3666
+ const delay = computeExponentialDelay(attempt, baseDelay, maxDelay, jitter);
3667
+ await sleepMs(delay);
3668
+ continue;
3669
+ }
3670
+
3671
+ return res;
3672
+ } catch (e) {
3673
+ clearTimeout(timer);
3674
+ if (opts.signal) opts.signal.removeEventListener("abort", onAbortExternal);
3675
+ const err = e as Error & { name?: string };
3676
+ lastError = err;
3677
+
3678
+ // AbortError retries only when it came from the internal timeout
3679
+ const isAbortTimeout = err.name === "AbortError";
3680
+ if (isAbortTimeout && attempt < maxRetries) {
3681
+ const delay = computeExponentialDelay(attempt, baseDelay, maxDelay, jitter);
3682
+ await sleepMs(delay);
3683
+ continue;
3684
+ }
3685
+
3686
+ if (attempt < maxRetries) {
3687
+ const delay = computeExponentialDelay(attempt, baseDelay, maxDelay, jitter);
3688
+ await sleepMs(delay);
3689
+ continue;
3690
+ }
3691
+
3692
+ throw lastError;
3693
+ }
3694
+ }
3695
+
3696
+ throw lastError ?? new Error("resilientFetch failed after retries");
3697
+ }
3698
+
3699
+ /**
3700
+ * High-level wrapper consuming streamGenerateContent with auto-recovery and
3701
+ * yielding SSE strings in the configured format. Tolerant to mid-stream breaks
3702
+ * (synthetic finish), missing finish reasons and UTF-8/JSON splits.
3703
+ *
3704
+ * @example
3705
+ * for await (const sseLine of createResilientTransformedStream(url, init, { model, format: "openai" })) {
3706
+ * res.write(sseLine);
3707
+ * }
3708
+ */
3709
+ export async function* createResilientTransformedStream(
3710
+ url: string,
3711
+ init: RequestInit,
3712
+ transformerOpts: StreamingTransformerOptions,
3713
+ fetchOpts: ResilientFetchOptions = {},
3714
+ ): AsyncGenerator<string, void, unknown> {
3715
+ const transformer = new CloudCodeStreamingTransformer(transformerOpts);
3716
+
3717
+ const response = await resilientFetch(url, init, fetchOpts);
3718
+
3719
+ if (!response.ok) {
3720
+ let errBody = "";
3721
+ try {
3722
+ errBody = await response.text();
3723
+ } catch {
3724
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3725
+ }
3726
+ throw new Error(`cloudcode-pa stream failed ${response.status}: ${errBody.slice(0, 500)}`);
3727
+ }
3728
+
3729
+ if (!response.body) {
3730
+ yield* transformer.flush();
3731
+ return;
3732
+ }
3733
+
3734
+ const reader = response.body.getReader();
3735
+ const decoder = new TextDecoder("utf-8", { fatal: false });
3736
+
3737
+ try {
3738
+ while (true) {
3739
+ const { done, value } = await reader.read();
3740
+ if (done) break;
3741
+ const sseLines = transformer.transformChunk(value);
3742
+ for (const line of sseLines) yield line;
3743
+ }
3744
+
3745
+ const finalLines = transformer.flush();
3746
+ for (const line of finalLines) yield line;
3747
+ } catch (_e) {
3748
+ // Mid-stream failure: degrade gracefully by flushing what we have
3749
+ try {
3750
+ const finalLines = transformer.flush();
3751
+ for (const line of finalLines) yield line;
3752
+ } catch {
3753
+ // ignore
3754
+ }
3755
+ } finally {
3756
+ try {
3757
+ await reader.cancel();
3758
+ } catch {
3759
+ /* the guarded best-effort operation falls through: the outer flow owns the failure */
3760
+ }
3761
+ }
3762
+ }
3763
+
3764
+ // ---------------------------------------------------------------------------
3765
+ // Merged: bypass headers + deterministic session id
3766
+ // ---------------------------------------------------------------------------
3767
+
3768
+ /**
3769
+ * Options for {@link buildGeminiCliBypassHeaders}.
3770
+ */
3771
+ export interface BypassHeadersOptions {
3772
+ model?: string;
3773
+ accessToken?: string;
3774
+ projectId?: string;
3775
+ includeAuth?: boolean;
3776
+ }
3777
+
3778
+ /**
3779
+ * Builds headers fully compatible with official Gemini CLI traffic against
3780
+ * cloudcode-pa, including the X-Goog-Request-Params project binding.
3781
+ *
3782
+ * @param opts - auth/project options
3783
+ * @returns headers record ready for fetch
3784
+ */
3785
+ export function buildGeminiCliBypassHeaders(opts: BypassHeadersOptions = {}): Record<string, string> {
3786
+ const h: Record<string, string> = {
3787
+ "User-Agent": GEMINI_CLI_USER_AGENT,
3788
+ "X-Goog-Api-Client": X_GOOG_API_CLIENT,
3789
+ "Client-Metadata": CLIENT_METADATA_RAW,
3790
+ "Content-Type": "application/json",
3791
+ Accept: "text/event-stream, application/json",
3792
+ "X-Goog-Request-Params": `project=${opts.projectId ?? PROJECT_FALLBACK}`,
3793
+ };
3794
+
3795
+ if (opts.includeAuth && opts.accessToken) {
3796
+ h.Authorization = `Bearer ${opts.accessToken}`;
3797
+ }
3798
+
3799
+ return h;
3800
+ }
3801
+
3802
+ /**
3803
+ * Deterministic session id (FNV-1a hex) derived from directory, hostname, cwd
3804
+ * and pid. Streaming-module variant kept alongside the request-module version.
3805
+ *
3806
+ * @param directory - optional directory seed
3807
+ * @returns 8-char hex digest
3808
+ */
3809
+ export function buildHostSessionId(directory?: string): string {
3810
+ const base = directory ?? `${os.hostname()}|${process.cwd()}|${process.pid}`;
3811
+ return fnv1a32HexBytes(base);
3812
+ }
3813
+
3814
+ // ---------------------------------------------------------------------------
3815
+ // Merged: plugin utilities
3816
+ // ---------------------------------------------------------------------------
3817
+
3818
+ /**
3819
+ * Detects whether any payload carries thinking content.
3820
+ *
3821
+ * @param payloads - normalized payloads
3822
+ */
3823
+ export function hasThinkingContent(payloads: NormalizedPayload[]): boolean {
3824
+ return payloads.some((p) => p.isThinkingChunk || p.parts.some((pt) => pt.thought));
3825
+ }
3826
+
3827
+ /**
3828
+ * Collects all distinct thinking signatures for multi-turn replay.
3829
+ *
3830
+ * @param payloads - normalized payloads
3831
+ * @returns unique signature strings
3832
+ */
3833
+ export function extractThinkingSignatures(payloads: NormalizedPayload[]): string[] {
3834
+ const sigs: string[] = [];
3835
+ for (const p of payloads) {
3836
+ for (const part of p.parts) {
3837
+ if (part.thoughtSignature && !sigs.includes(part.thoughtSignature)) sigs.push(part.thoughtSignature);
3838
+ }
3839
+ }
3840
+ return sigs;
3841
+ }
3842
+
3843
+ /**
3844
+ * Exposes accumulated state metrics for observability in the opencode UI.
3845
+ *
3846
+ * @param state - streaming state
3847
+ */
3848
+ export function getStreamingSummary(state: StreamingState): {
3849
+ completionId: string;
3850
+ model: string;
3851
+ textChars: number;
3852
+ thinkingChars: number;
3853
+ finishReason?: string | undefined;
3854
+ toolCalls: number;
3855
+ pendingSignatures: number;
3856
+ } {
3857
+ return {
3858
+ completionId: state.completionId,
3859
+ model: state.model,
3860
+ textChars: state.accumulatedText.length,
3861
+ thinkingChars: state.accumulatedThinking.length,
3862
+ finishReason: state.lastFinishReason,
3863
+ toolCalls: state.toolCallIdCounter,
3864
+ pendingSignatures: state.pendingSignatures.length,
3865
+ };
3866
+ }
3867
+
3868
+ /**
3869
+ * Passes through the raw candidate payload when present, otherwise rebuilds a
3870
+ * minimal Gemini-shaped response from the normalized view.
3871
+ *
3872
+ * @param p - normalized payload-like object
3873
+ * @returns raw payload or reconstructed Gemini candidates envelope
3874
+ */
3875
+ export const transformToGemini = (p: any): any =>
3876
+ p.raw || { candidates: [{ content: { parts: [{ text: p.text || "" }] } }] };
3877
+
3878
+ // ---------------------------------------------------------------------------
3879
+ // Merged: Streaming named barrel (default export remains streamingModule)
3880
+ // ---------------------------------------------------------------------------
3881
+
3882
+ /**
3883
+ * Named bundle mirroring the legacy ONDA 4 module surface. Renamed members are
3884
+ * wired to their merged equivalents: `parseSSEPayloads`, `normalizeRawPayloads`
3885
+ * and the module-level `isRetryableNetworkStatus`.
3886
+ */
3887
+ export const Streaming = {
3888
+ IncrementalSSEParser,
3889
+ CloudCodeStreamingTransformer,
3890
+ ThinkingSignatureLRUCache,
3891
+ globalThinkingSignatureCache,
3892
+ globalThinkingSignatureLRUCache,
3893
+
3894
+ parseSSEPayloads,
3895
+ normalizeRawPayloads,
3896
+
3897
+ transformToOpenAIChunks,
3898
+ transformToAnthropicEvents,
3899
+ serializeOpenAIChunksToSSE,
3900
+ createSyntheticFinishChunks,
3901
+ createAnthropicSyntheticFinish,
3902
+ createInitialStreamingState,
3903
+
3904
+ resilientFetch,
3905
+ createResilientTransformedStream,
3906
+ computeExponentialDelay,
3907
+ isRetryableNetworkStatus,
3908
+
3909
+ buildGeminiCliBypassHeaders,
3910
+ buildHostSessionId,
3911
+
3912
+ hasThinkingContent,
3913
+ extractThinkingSignatures,
3914
+ getStreamingSummary,
3915
+ transformToGemini,
3916
+ handleThinkingBlock,
3917
+
3918
+ GEMINI_CLI_OAUTH_CLIENT_ID,
3919
+ GEMINI_CLI_OAUTH_CLIENT_SECRET,
3920
+ GEMINI_CLI_USER_AGENT,
3921
+ X_GOOG_API_CLIENT,
3922
+ CLIENT_METADATA_RAW,
3923
+ PROJECT_FALLBACK,
3924
+ MODELS_2026,
3925
+ } as const;
3926
+
3927
+ // ---------------------------------------------------------------------------
3928
+ // Exports — library-first barrel
3929
+ // ---------------------------------------------------------------------------
3930
+
3931
+ /**
3932
+ * Default export for barrel compatibility: contains all primary helpers
3933
+ */
3934
+ const streamingModule = {
3935
+ LRU_THINKING_CACHE_SIZE,
3936
+ ReplaySignatureCache,
3937
+ globalThinkingSignatureCache,
3938
+ createSSEParserState,
3939
+ parseSSEChunk,
3940
+ parseSSEBinaryChunk,
3941
+ safeJsonParse,
3942
+ unwrapCandidates,
3943
+ extractParts,
3944
+ normalizePart,
3945
+ normalizePayloads,
3946
+ mapFinishReason,
3947
+ extractDelta,
3948
+ StreamingSessionTracker,
3949
+ transformToOpenAI,
3950
+ createOpenAIStreamingTransformer,
3951
+ transformToAnthropic,
3952
+ createAnthropicStreamingTransformer,
3953
+ fetchWithExponentialRetry,
3954
+ injectSyntheticFinish,
3955
+ withAutoRecoveryStreaming,
3956
+ readSSEStream,
3957
+ streamToOpenAI,
3958
+ streamToAnthropic,
3959
+ fnv1a32,
3960
+ hashThinkingText,
3961
+ getJitter,
3962
+ sleepWithJitter,
3963
+ SSE_DONE_SENTINEL,
3964
+ };
3965
+
3966
+ export default streamingModule;
3967
+
3968
+ /* ════════════════════════════════════════════════════════════════════
3969
+ Section: the devthink workbench stream surface (the 1.1.16 ChatEvent
3970
+ family absorbed by the grand merge; the chat event vocabulary bridges
3971
+ onto the normalized streaming machinery above).
3972
+ ════════════════════════════════════════════════════════════════════ */
3973
+ export type ChatEvent =
3974
+ | { type: "start"; provider: string; model: string }
3975
+ | { type: "text"; text: string }
3976
+ | { type: "reasoning"; text: string }
3977
+ | { type: "tool"; name: string; input: unknown }
3978
+ | { type: "error"; message: string }
3979
+ | { type: "finish"; reason?: string };
3980
+
3981
+ export type StreamOptions = {
3982
+ provider: string;
3983
+ model: string;
3984
+ signal?: AbortSignal | undefined;
3985
+ };
3986
+
3987
+ function decodeJson(value: string): Record<string, unknown> | null {
3988
+ try {
3989
+ const parsed: unknown = JSON.parse(value);
3990
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
3991
+ } catch {
3992
+ return null;
3993
+ }
3994
+ }
3995
+
3996
+ function extractSseFrames(buffer: { value: string }, incoming: string): string[] {
3997
+ buffer.value += incoming;
3998
+ const frames = buffer.value.split(/\r?\n\r?\n/);
3999
+ buffer.value = frames.pop() || "";
4000
+ return frames;
4001
+ }
4002
+
4003
+ function extractData(frame: string): string | null {
4004
+ const lines = frame.split(/\r?\n/).filter((line) => line.startsWith("data:"));
4005
+ if (lines.length === 0) return null;
4006
+ const value = lines
4007
+ .map((line) => line.slice(5).trimStart())
4008
+ .join("\n")
4009
+ .trim();
4010
+ return value && value !== "[DONE]" ? value : null;
4011
+ }
4012
+
4013
+ function textAt(value: unknown): string {
4014
+ return typeof value === "string" ? value : "";
4015
+ }
4016
+
4017
+ function normalizeOpenAi(frame: Record<string, unknown>): ChatEvent[] {
4018
+ const choices = Array.isArray(frame.choices) ? frame.choices : [];
4019
+ const choice = choices[0] as Record<string, unknown> | undefined;
4020
+ const delta = (choice?.delta || {}) as Record<string, unknown>;
4021
+ const events: ChatEvent[] = [];
4022
+ const reasoning = textAt(delta.reasoning_content || delta.reasoning);
4023
+ const text = textAt(delta.content);
4024
+ if (reasoning) events.push({ type: "reasoning", text: reasoning });
4025
+ if (text) events.push({ type: "text", text });
4026
+ const finish = choice?.finish_reason;
4027
+ if (finish) events.push({ type: "finish", reason: textAt(finish) });
4028
+ return events;
4029
+ }
4030
+
4031
+ function normalizeAnthropic(frame: Record<string, unknown>): ChatEvent[] {
4032
+ const type = textAt(frame.type);
4033
+ if (type === "content_block_delta") {
4034
+ const delta = (frame.delta || {}) as Record<string, unknown>;
4035
+ const text = textAt(delta.text);
4036
+ return text ? [{ type: "text", text }] : [];
4037
+ }
4038
+ if (type === "message_stop") return [{ type: "finish", reason: "stop" }];
4039
+ if (type === "error") {
4040
+ const error = (frame.error || {}) as Record<string, unknown>;
4041
+ return [{ type: "error", message: textAt(error.message) || "Anthropic returned an error." }];
4042
+ }
4043
+ return [];
4044
+ }
4045
+
4046
+ function normalizeGoogle(frame: Record<string, unknown>): ChatEvent[] {
4047
+ const candidates = Array.isArray(frame.candidates) ? frame.candidates : [];
4048
+ const content = ((candidates[0] as Record<string, unknown> | undefined)?.content || {}) as Record<string, unknown>;
4049
+ const parts = Array.isArray(content.parts) ? content.parts : [];
4050
+ const events: ChatEvent[] = [];
4051
+ for (const part of parts) {
4052
+ const item = part as Record<string, unknown>;
4053
+ const text = textAt(item.text);
4054
+ if (text) events.push({ type: "text", text });
4055
+ }
4056
+ const finish = textAt((candidates[0] as Record<string, unknown> | undefined)?.finishReason);
4057
+ if (finish) events.push({ type: "finish", reason: finish });
4058
+ return events;
4059
+ }
4060
+
4061
+ export function normalizeFrame(provider: string, frame: Record<string, unknown>): ChatEvent[] {
4062
+ const lower = provider.toLowerCase();
4063
+ if (lower === "anthropic") return normalizeAnthropic(frame);
4064
+ if (lower === "google") return normalizeGoogle(frame);
4065
+ const data = (frame.data || frame) as Record<string, unknown>;
4066
+ if (data !== frame && textAt(data.delta_content)) return [{ type: "text", text: textAt(data.delta_content) }];
4067
+ if (data !== frame && textAt(data.reasoning_content))
4068
+ return [{ type: "reasoning", text: textAt(data.reasoning_content) }];
4069
+ if (data.error || frame.error)
4070
+ return [{ type: "error", message: textAt(data.error || frame.error) || "The provider returned an error." }];
4071
+ return normalizeOpenAi(frame);
4072
+ }
4073
+
4074
+ export async function* parseEventStream(response: Response, options: StreamOptions): AsyncGenerator<ChatEvent> {
4075
+ if (!response.body) throw new Error("The provider returned an empty stream.");
4076
+ yield { type: "start", provider: options.provider, model: options.model };
4077
+ const reader = response.body.getReader();
4078
+ const decoder = new TextDecoder();
4079
+ const buffer = { value: "" };
4080
+ try {
4081
+ while (true) {
4082
+ const { done, value } = await reader.read();
4083
+ if (done) break;
4084
+ for (const frame of extractSseFrames(buffer, decoder.decode(value, { stream: true }))) {
4085
+ const payload = extractData(frame);
4086
+ if (!payload) continue;
4087
+ const parsed = decodeJson(payload);
4088
+ if (!parsed) continue;
4089
+ for (const event of normalizeFrame(options.provider, parsed)) yield event;
4090
+ }
4091
+ }
4092
+ } finally {
4093
+ reader.releaseLock();
4094
+ }
4095
+ yield { type: "finish", reason: "stop" };
4096
+ }
4097
+
4098
+ export async function collectEvents(events: AsyncIterable<ChatEvent>): Promise<{ text: string; reasoning: string }> {
4099
+ let text = "";
4100
+ let reasoning = "";
4101
+ for await (const event of events) {
4102
+ if (event.type === "text") text += event.text;
4103
+ if (event.type === "reasoning") reasoning += event.text;
4104
+ if (event.type === "error") throw new Error(event.message);
4105
+ }
4106
+ return { text, reasoning };
4107
+ }
4108
+
4109
+ /* ===== block: streamfallback ===== */
4110
+ /**
4111
+ * streamfallback.ts — the model fallback of the streaming routes (root layer).
4112
+ *
4113
+ * the error catalog the gateway matches every upstream failure against and the
4114
+ * fallback runner that walks the route chain when one model dies. The verdict
4115
+ * vocabulary follows the declarative chains of omniroute (priority order,
4116
+ * already-tried routes excluded) and stays local to this file; the retry curve
4117
+ * is the house retry.backoff discipline the request queue family owns,
4118
+ * consumed through the published @wenathlan/devthink/queue export — the
4119
+ * doubling curve, the ±20% jitter, the abortable sleep and the Retry-After
4120
+ * parsing answer from the owner (the manifest debt of the ownership law is
4121
+ * paid; the local backoff copy retired). Wire: the streaming routes (the
4122
+ * PARSERSTEPS pattern of gateway.ts) wrap their upstream call with
4123
+ * `runwithfallback`; failures land on the ApiKeyRow counters of db.ts through
4124
+ * `applyerrortokey` and the trail feeds the chat log rows.
4125
+ */
4126
+
4127
+
4128
+
4129
+
4130
+ /** the error catalog of the house: every upstream failure answers exactly one code. */
4131
+ export type StreamErrorCode =
4132
+ | "transport"
4133
+ | "timeout"
4134
+ | "unauthorized"
4135
+ | "forbidden"
4136
+ | "notfound"
4137
+ | "gone"
4138
+ | "unprocessable"
4139
+ | "ratelimited"
4140
+ | "overloaded"
4141
+ | "server"
4142
+ | "unknown";
4143
+
4144
+ /** what the runner does with a classified failure. */
4145
+ export type FallbackVerdict = "retry" | "fallback" | "abort";
4146
+
4147
+ /** one classified upstream failure (throw it from the attempt callback). */
4148
+ export class StreamError extends Error {
4149
+ /** the http status, or 0 for a transport-level failure */
4150
+ status: number;
4151
+ /** the catalog code the failure classified to */
4152
+ code: StreamErrorCode;
4153
+ /** server hint from `Retry-After`, in ms, when advertised */
4154
+ retryAfterMs?: number;
4155
+
4156
+ constructor(message: string, status: number, code: StreamErrorCode, retryAfterMs?: number) {
4157
+ super(message);
4158
+ this.name = "StreamError";
4159
+ this.status = status;
4160
+ this.code = code;
4161
+ this.retryAfterMs = retryAfterMs;
4162
+ }
4163
+ }
4164
+
4165
+ /** thrown when every route of the chain is exhausted; carries the full trail. */
4166
+ export class StreamFallbackError extends StreamError {
4167
+ /** every try the runner made, in order */
4168
+ trail: FallbackStep[];
4169
+
4170
+ constructor(message: string, status: number, code: StreamErrorCode, trail: FallbackStep[]) {
4171
+ super(message, status, code);
4172
+ this.name = "StreamFallbackError";
4173
+ this.trail = trail;
4174
+ }
4175
+ }
4176
+
4177
+ /** one recorded try of the runner (the traceability the chat log renders). */
4178
+ export type FallbackStep = {
4179
+ routeId: string;
4180
+ /** the 1-based attempt within the route */
4181
+ attempt: number;
4182
+ /** the http status (0 for transport failures) */
4183
+ status: number;
4184
+ /** the catalog code, or null when the try answered */
4185
+ code: StreamErrorCode | null;
4186
+ /** the backoff waited before the next try (0 on success and exhaustion) */
4187
+ delayMs: number;
4188
+ };
4189
+
4190
+ /** one route of the fallback chain (the minimal shape the runner needs). */
4191
+ export type FallbackRoute = {
4192
+ id: string;
4193
+ /** lower walks first in the chain */
4194
+ priority: number;
4195
+ enabled: boolean;
4196
+ };
4197
+
4198
+ /** the runner knobs. */
4199
+ export type FallbackOptions = {
4200
+ /** retries of the SAME route for retry-verdict failures (default 1) */
4201
+ retriesPerRoute: number;
4202
+ /** the first backoff delay in ms (default 250) */
4203
+ initialDelayMs: number;
4204
+ /** the backoff ceiling in ms (default 4000) */
4205
+ maxDelayMs: number;
4206
+ /** ± ratio applied to the computed delay (default 0.2) */
4207
+ jitterRatio: number;
4208
+ /** progress hook fired after every try */
4209
+ onstep?: (step: FallbackStep) => void;
4210
+ };
4211
+
4212
+ /**
4213
+ * the house knobs for the streaming routes: the timing values the runner hands
4214
+ * to the queue family's backoff curve — 250ms initial doubling (×2), capped at
4215
+ * 4s, ±20% jitter — the discipline itself lives in @wenathlan/devthink/queue.
4216
+ */
4217
+ export const FALLBACKDEFAULTS: FallbackOptions = {
4218
+ retriesPerRoute: 1,
4219
+ initialDelayMs: 250,
4220
+ maxDelayMs: 4000,
4221
+ jitterRatio: 0.2,
4222
+ };
4223
+
4224
+ /**
4225
+ * classifystatus — the catalog: 0 transport, 408/404-timeout semantics via
4226
+ * timeout, 401 unauthorized, 403 forbidden, 404 notfound, 410 gone, 422
4227
+ * unprocessable, 429 ratelimited, 529 overloaded, other 5xx server, the rest
4228
+ * unknown.
4229
+ *
4230
+ * @param status the http status, 0 for transport failures.
4231
+ * @returns the catalog code.
4232
+ */
4233
+ export function classifystatus(status: number): StreamErrorCode {
4234
+ if (status === 0) return "transport";
4235
+ if (status === 401) return "unauthorized";
4236
+ if (status === 403) return "forbidden";
4237
+ if (status === 404) return "notfound";
4238
+ if (status === 408) return "timeout";
4239
+ if (status === 410) return "gone";
4240
+ if (status === 422) return "unprocessable";
4241
+ if (status === 429) return "ratelimited";
4242
+ if (status === 529) return "overloaded";
4243
+ if (status >= 500 && status <= 599) return "server";
4244
+ return "unknown";
4245
+ }
4246
+
4247
+ /**
4248
+ * verdictfor — what the runner does per catalog code. timeout, transport and
4249
+ * server failures retry the same route first (transient); unauthorized,
4250
+ * forbidden, notfound, gone, unprocessable, ratelimited and overloaded fall
4251
+ * back to the next route (retrying can never succeed on the same model);
4252
+ * unknown failures abort the chain (a bug must not turn into a storm).
4253
+ *
4254
+ * @param code the catalog code.
4255
+ * @returns the verdict.
4256
+ */
4257
+ export function verdictfor(code: StreamErrorCode): FallbackVerdict {
4258
+ if (code === "timeout" || code === "transport" || code === "server") return "retry";
4259
+ if (code === "unknown") return "abort";
4260
+ return "fallback";
4261
+ }
4262
+
4263
+ /**
4264
+ * errorfromresponse — builds the classified failure from a fetch `Response`,
4265
+ * reading the `Retry-After` hint into the error so the runner honors it over
4266
+ * the computed backoff (the hint parsing is the queue family's
4267
+ * `parseRetryAfterMs`, clamped to the house cap). Non-StreamError throws
4268
+ * inside the attempt callback classify as `unknown` and abort the chain (a bug
4269
+ * must not turn into a storm).
4270
+ *
4271
+ * @param response the failed fetch response (consumed or not, body untouched).
4272
+ * @param now the reference clock for http-date hints (defaults to the real clock).
4273
+ * @param maxDelayMs the clamp applied to the parsed hint (defaults to the house cap).
4274
+ * @returns the StreamError to throw from the attempt callback.
4275
+ */
4276
+ export function errorfromresponse(
4277
+ response: Response,
4278
+ now = Date.now(),
4279
+ maxDelayMs = FALLBACKDEFAULTS.maxDelayMs,
4280
+ ): StreamError {
4281
+ const code = classifystatus(response.status);
4282
+ return new StreamError(
4283
+ `upstream answered ${response.status}`,
4284
+ response.status,
4285
+ code,
4286
+ parseRetryAfterMs(response.headers.get("retry-after"), now, maxDelayMs),
4287
+ );
4288
+ }
4289
+
4290
+ /**
4291
+ * retryconfigof — translates the runner knobs into the owner's retry policy:
4292
+ * the queue family's `delayForAttempt` curve reads a {@link RetryConfig}, and
4293
+ * the gateway knobs map onto it one to one — `initialDelayMs` rides
4294
+ * `initialIntervalMs`, `maxDelayMs` rides `maxIntervalMs`, `jitterRatio`
4295
+ * rides verbatim, and the doubling the retired local copy hardcoded rides as
4296
+ * `backoffMultiplier` 2 (the same curve, owned by the queue family). The
4297
+ * `retriesPerRoute` budget rides `maxRetries` so the translation stays one
4298
+ * honest policy object.
4299
+ *
4300
+ * @param options the runner knobs (the merged {@link FALLBACKDEFAULTS}).
4301
+ * @returns the owner's retry policy for the same curve.
4302
+ */
4303
+ function retryconfigof(options: FallbackOptions): RetryConfig {
4304
+ return {
4305
+ maxRetries: options.retriesPerRoute,
4306
+ initialIntervalMs: options.initialDelayMs,
4307
+ backoffMultiplier: 2,
4308
+ maxIntervalMs: options.maxDelayMs,
4309
+ jitterRatio: options.jitterRatio,
4310
+ };
4311
+ }
4312
+
4313
+ /**
4314
+ * orders the chain the way the runner walks it: enabled routes only, priority
4315
+ * ascending, stable within ties (the omniroute registration discipline).
4316
+ *
4317
+ * @param routes the registered chain.
4318
+ * @returns the ordered routes.
4319
+ */
4320
+ export function orderchain(routes: readonly FallbackRoute[]): FallbackRoute[] {
4321
+ return routes
4322
+ .filter((route) => route.enabled)
4323
+ .map((route, index) => ({ route, index }))
4324
+ .sort((left, right) => left.route.priority - right.route.priority || left.index - right.index)
4325
+ .map((entry) => entry.route);
4326
+ }
4327
+
4328
+ /**
4329
+ * runwithfallback — runs `attempt(route)` over the chain: retry-verdict
4330
+ * failures retry the same route up to `retriesPerRoute`, fallback-verdict
4331
+ * failures move to the next route, abort-verdict failures stop the chain.
4332
+ * The `Retry-After` hint of a thrown StreamError wins over the computed
4333
+ * backoff, and the computed backoff itself is the queue family's
4334
+ * `delayForAttempt` curve over the runner knobs (the discipline answers from
4335
+ * @wenathlan/devthink/queue, never from a local copy). Answers the first
4336
+ * success; throws the last failure (a StreamFallbackError carrying the full
4337
+ * trail) when the chain is exhausted.
4338
+ *
4339
+ * @typeParam T the attempt result.
4340
+ * @param routes the registered chain (any order; {@link orderchain} sorts).
4341
+ * @param attempt the operation for one route; called again from scratch per try.
4342
+ * @param opts the optional knobs and progress hook.
4343
+ * @returns the first successful result.
4344
+ */
4345
+ export async function runwithfallback<T>(
4346
+ routes: readonly FallbackRoute[],
4347
+ attempt: (route: FallbackRoute) => Promise<T>,
4348
+ opts: Partial<FallbackOptions> = {},
4349
+ ): Promise<T> {
4350
+ const options: FallbackOptions = { ...FALLBACKDEFAULTS, ...opts };
4351
+ const chain = orderchain(routes);
4352
+ const trail: FallbackStep[] = [];
4353
+ for (const route of chain) {
4354
+ for (let attemptNo = 1; attemptNo <= options.retriesPerRoute + 1; attemptNo++) {
4355
+ try {
4356
+ const value = await attempt(route);
4357
+ trail.push({ routeId: route.id, attempt: attemptNo, status: 0, code: null, delayMs: 0 });
4358
+ options.onstep?.(trail[trail.length - 1]);
4359
+ return value;
4360
+ } catch (error) {
4361
+ const streamError = error instanceof StreamError ? error : new StreamError(String(error), 0, "unknown");
4362
+ const code = streamError.code;
4363
+ const verdict = verdictfor(code);
4364
+ const hint = streamError.retryAfterMs;
4365
+ const delayMs =
4366
+ verdict === "fallback" || attemptNo > options.retriesPerRoute
4367
+ ? 0
4368
+ : (hint ?? delayForAttempt(retryconfigof(options), attemptNo));
4369
+ trail.push({ routeId: route.id, attempt: attemptNo, status: streamError.status, code, delayMs });
4370
+ options.onstep?.(trail[trail.length - 1]);
4371
+ if (verdict === "abort") throw new StreamFallbackError(streamError.message, streamError.status, code, trail);
4372
+ if (verdict === "fallback" || attemptNo > options.retriesPerRoute) break;
4373
+ await sleepMs(delayMs);
4374
+ }
4375
+ }
4376
+ }
4377
+ const last = trail[trail.length - 1];
4378
+ const code = last?.code ?? "unknown";
4379
+ throw new StreamFallbackError(
4380
+ `streamfallback: every route failed (${trail.length} tries)`,
4381
+ last?.status ?? 0,
4382
+ code,
4383
+ trail,
4384
+ );
4385
+ }
4386
+
4387
+ /**
4388
+ * applyerrortokey — lands one upstream failure on the key row of db.ts: the
4389
+ * error count goes up and a 429 flips the rate-limit flag. Revocation stays
4390
+ * the operator's call; the runner never retires a key on its own.
4391
+ *
4392
+ * @param key the key row the failure hit.
4393
+ * @param status the http status of the failure.
4394
+ * @returns the updated row (a new object — the input row is never mutated).
4395
+ */
4396
+ export function applyerrortokey(key: ApiKeyRow, status: number): ApiKeyRow {
4397
+ return { ...key, errorCount: key.errorCount + 1, rateLimitHit: status === 429 ? true : key.rateLimitHit };
4398
+ }