mixdog 0.7.7 → 0.7.11

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.
@@ -8,10 +8,14 @@ import { withRetry } from './retry-classifier.mjs';
8
8
  import { traceBridgeUsage, appendBridgeTrace } from '../bridge-trace.mjs';
9
9
  import {
10
10
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
11
+ PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS,
11
12
  PROVIDER_GENERATE_TOTAL_TIMEOUT_MS,
12
13
  PROVIDER_MAX_BEFORE_WARN_MS,
14
+ PROVIDER_SSE_IDLE_TIMEOUT_MS,
15
+ PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
13
16
  providerTimeoutError,
14
17
  resolveTimeoutMs,
18
+ createTimeoutSignal,
15
19
  } from '../stall-policy.mjs';
16
20
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
17
21
 
@@ -357,6 +361,323 @@ function runGeminiOperationWithTimeout({ label, timeoutMs, signal, run }) {
357
361
  });
358
362
  }
359
363
 
364
+ function geminiTruncatedStreamError(message) {
365
+ return Object.assign(
366
+ new Error(message),
367
+ { name: 'TruncatedStreamError', code: 'TRUNCATED_STREAM', truncatedStream: true },
368
+ );
369
+ }
370
+
371
+ /**
372
+ * Aggregate streamed GenerateContentResponse chunks into one response object
373
+ * (same shape as a non-streaming generateContent JSON body).
374
+ * Mirrors @google/generative-ai aggregateResponses().
375
+ */
376
+ function aggregateGeminiStreamChunks(responses) {
377
+ const lastResponse = responses[responses.length - 1];
378
+ const aggregatedResponse = {
379
+ promptFeedback: lastResponse?.promptFeedback,
380
+ };
381
+ for (const response of responses) {
382
+ if (response?.candidates) {
383
+ let candidateIndex = 0;
384
+ for (const candidate of response.candidates) {
385
+ if (!aggregatedResponse.candidates) aggregatedResponse.candidates = [];
386
+ if (!aggregatedResponse.candidates[candidateIndex]) {
387
+ aggregatedResponse.candidates[candidateIndex] = { index: candidateIndex };
388
+ }
389
+ const aggCand = aggregatedResponse.candidates[candidateIndex];
390
+ aggCand.citationMetadata = candidate.citationMetadata;
391
+ aggCand.groundingMetadata = candidate.groundingMetadata;
392
+ aggCand.finishReason = candidate.finishReason;
393
+ aggCand.finishMessage = candidate.finishMessage;
394
+ aggCand.safetyRatings = candidate.safetyRatings;
395
+ if (candidate.content?.parts) {
396
+ if (!aggCand.content) {
397
+ aggCand.content = {
398
+ role: candidate.content.role || 'user',
399
+ parts: [],
400
+ };
401
+ }
402
+ for (const part of candidate.content.parts) {
403
+ const newPart = {};
404
+ if (part.text) newPart.text = part.text;
405
+ if (part.functionCall) newPart.functionCall = part.functionCall;
406
+ if (part.thoughtSignature) newPart.thoughtSignature = part.thoughtSignature;
407
+ if (part.thought_signature) newPart.thought_signature = part.thought_signature;
408
+ if (part.executableCode) newPart.executableCode = part.executableCode;
409
+ if (part.codeExecutionResult) newPart.codeExecutionResult = part.codeExecutionResult;
410
+ if (Object.keys(newPart).length === 0) newPart.text = '';
411
+ aggCand.content.parts.push(newPart);
412
+ }
413
+ }
414
+ candidateIndex++;
415
+ }
416
+ }
417
+ if (response?.usageMetadata) aggregatedResponse.usageMetadata = response.usageMetadata;
418
+ }
419
+ return aggregatedResponse;
420
+ }
421
+
422
+ function assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label }) {
423
+ if (!sawStreamChunk) {
424
+ throw geminiTruncatedStreamError(`${label} truncated: empty stream`);
425
+ }
426
+ if (!finishReason) {
427
+ throw geminiTruncatedStreamError(`${label} truncated: no finishReason`);
428
+ }
429
+ }
430
+
431
+ async function consumeGeminiRestStreamResponse(response, { signal, onStreamDelta, label }) {
432
+ if (!response?.body) throw new Error(`${label}: missing response body`);
433
+ const reader = response.body.getReader();
434
+ const decoder = new TextDecoder();
435
+ let buffer = '';
436
+ const allChunks = [];
437
+ let sawStreamChunk = false;
438
+ let idleTimedOut = false;
439
+ let idleTimer = null;
440
+ let idleReject = null;
441
+
442
+ let firstByteTimer = setTimeout(() => {
443
+ try { reader.cancel('first byte timeout'); } catch {}
444
+ if (idleReject) {
445
+ const e = geminiTimeoutError(`${label} first byte`, GEMINI_FIRST_BYTE_TIMEOUT_MS);
446
+ const r = idleReject; idleReject = null; r(e);
447
+ }
448
+ }, GEMINI_FIRST_BYTE_TIMEOUT_MS);
449
+ if (firstByteTimer.unref) firstByteTimer.unref();
450
+
451
+ const clearFirstByteTimer = () => {
452
+ if (firstByteTimer) {
453
+ clearTimeout(firstByteTimer);
454
+ firstByteTimer = null;
455
+ }
456
+ };
457
+
458
+ const resetIdleTimer = () => {
459
+ if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED) return;
460
+ if (idleTimer) clearTimeout(idleTimer);
461
+ idleTimer = setTimeout(() => {
462
+ idleTimedOut = true;
463
+ try { reader.cancel('SSE idle timeout'); } catch {}
464
+ if (idleReject) {
465
+ const e = geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
466
+ const r = idleReject; idleReject = null; r(e);
467
+ }
468
+ }, PROVIDER_SSE_IDLE_TIMEOUT_MS);
469
+ if (idleTimer.unref) idleTimer.unref();
470
+ };
471
+
472
+ const onAbort = () => {
473
+ try {
474
+ const c = reader.cancel('aborted');
475
+ if (c && typeof c.catch === 'function') c.catch(() => {});
476
+ } catch {}
477
+ };
478
+
479
+ if (signal) {
480
+ if (signal.aborted) {
481
+ const reason = signal.reason;
482
+ throw reason instanceof Error ? reason : new Error(`${label} aborted`);
483
+ }
484
+ signal.addEventListener('abort', onAbort, { once: true });
485
+ }
486
+
487
+ try {
488
+ resetIdleTimer();
489
+ while (true) {
490
+ let chunk;
491
+ try {
492
+ chunk = await new Promise((resolve, reject) => {
493
+ idleReject = reject;
494
+ reader.read().then(resolve, reject);
495
+ });
496
+ } catch (err) {
497
+ if (idleTimedOut) {
498
+ throw geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
499
+ }
500
+ if (signal?.aborted) {
501
+ const reason = signal.reason;
502
+ throw reason instanceof Error ? reason : new Error(`${label} aborted`);
503
+ }
504
+ throw err;
505
+ } finally {
506
+ idleReject = null;
507
+ }
508
+ const { done, value } = chunk;
509
+ if (done) break;
510
+ resetIdleTimer();
511
+ buffer += decoder.decode(value, { stream: true });
512
+ let lineEnd;
513
+ while ((lineEnd = buffer.indexOf('\n')) >= 0) {
514
+ let line = buffer.slice(0, lineEnd);
515
+ buffer = buffer.slice(lineEnd + 1);
516
+ line = line.replace(/\r$/, '');
517
+ if (!line.startsWith('data: ')) continue;
518
+ const data = line.slice(6).trim();
519
+ if (!data || data === '[DONE]') continue;
520
+ let parsed;
521
+ try { parsed = JSON.parse(data); } catch { continue; }
522
+ if (!sawStreamChunk) {
523
+ sawStreamChunk = true;
524
+ clearFirstByteTimer();
525
+ }
526
+ allChunks.push(parsed);
527
+ try { onStreamDelta?.(); } catch {}
528
+ }
529
+ }
530
+ if (buffer.trim()) {
531
+ const line = buffer.trim().replace(/\r$/, '');
532
+ if (line.startsWith('data: ')) {
533
+ const data = line.slice(6).trim();
534
+ if (data && data !== '[DONE]') {
535
+ try {
536
+ const parsed = JSON.parse(data);
537
+ if (!sawStreamChunk) {
538
+ sawStreamChunk = true;
539
+ clearFirstByteTimer();
540
+ }
541
+ allChunks.push(parsed);
542
+ try { onStreamDelta?.(); } catch {}
543
+ } catch { /* skip malformed tail */ }
544
+ }
545
+ }
546
+ }
547
+ } finally {
548
+ clearFirstByteTimer();
549
+ if (idleTimer) clearTimeout(idleTimer);
550
+ if (signal) signal.removeEventListener('abort', onAbort);
551
+ try { reader.releaseLock(); } catch {}
552
+ }
553
+
554
+ const aggregated = aggregateGeminiStreamChunks(allChunks);
555
+ const finishReason = aggregated.candidates?.[0]?.finishReason || null;
556
+ assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
557
+ return aggregated;
558
+ }
559
+
560
+ async function consumeGeminiSdkStream(streamResult, { signal, onStreamDelta, label }) {
561
+ let sawStreamChunk = false;
562
+ let idleTimedOut = false;
563
+ let idleTimer = null;
564
+ let firstByteReject = null;
565
+ let firstByteTimer = null;
566
+ let inFlightReject = null;
567
+
568
+ const armFirstByteTimer = () => {
569
+ if (firstByteTimer) clearTimeout(firstByteTimer);
570
+ firstByteTimer = setTimeout(() => {
571
+ if (firstByteReject) {
572
+ const e = geminiTimeoutError(`${label} first byte`, GEMINI_FIRST_BYTE_TIMEOUT_MS);
573
+ const r = firstByteReject; firstByteReject = null; r(e);
574
+ }
575
+ }, GEMINI_FIRST_BYTE_TIMEOUT_MS);
576
+ if (firstByteTimer.unref) firstByteTimer.unref();
577
+ };
578
+
579
+ const clearFirstByteTimer = () => {
580
+ if (firstByteTimer) {
581
+ clearTimeout(firstByteTimer);
582
+ firstByteTimer = null;
583
+ }
584
+ firstByteReject = null;
585
+ };
586
+
587
+ const resetIdleTimer = () => {
588
+ if (!PROVIDER_SSE_IDLE_WATCHDOG_ENABLED) return;
589
+ if (idleTimer) clearTimeout(idleTimer);
590
+ idleTimer = setTimeout(() => {
591
+ idleTimedOut = true;
592
+ if (inFlightReject) {
593
+ const e = geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
594
+ const r = inFlightReject; inFlightReject = null; r(e);
595
+ }
596
+ }, PROVIDER_SSE_IDLE_TIMEOUT_MS);
597
+ if (idleTimer.unref) idleTimer.unref();
598
+ };
599
+
600
+ if (signal?.aborted) {
601
+ const reason = signal.reason;
602
+ throw reason instanceof Error ? reason : new Error(`${label} aborted`);
603
+ }
604
+
605
+ const iterator = streamResult.stream[Symbol.asyncIterator]();
606
+
607
+ try {
608
+ armFirstByteTimer();
609
+ resetIdleTimer();
610
+ while (true) {
611
+ if (idleTimedOut) {
612
+ throw geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
613
+ }
614
+ let step;
615
+ try {
616
+ step = await new Promise((resolve, reject) => {
617
+ inFlightReject = reject;
618
+ if (!sawStreamChunk) firstByteReject = reject;
619
+ iterator.next().then(
620
+ (value) => {
621
+ inFlightReject = null;
622
+ firstByteReject = null;
623
+ resolve(value);
624
+ },
625
+ (err) => {
626
+ inFlightReject = null;
627
+ firstByteReject = null;
628
+ reject(err);
629
+ },
630
+ );
631
+ });
632
+ } catch (err) {
633
+ if (idleTimedOut) {
634
+ throw geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
635
+ }
636
+ if (signal?.aborted) {
637
+ const reason = signal.reason;
638
+ throw reason instanceof Error ? reason : new Error(`${label} aborted`);
639
+ }
640
+ throw err;
641
+ }
642
+ if (step.done) break;
643
+ if (!sawStreamChunk) {
644
+ sawStreamChunk = true;
645
+ clearFirstByteTimer();
646
+ }
647
+ resetIdleTimer();
648
+ try { onStreamDelta?.(); } catch {}
649
+ }
650
+ if (idleTimedOut) {
651
+ throw geminiTimeoutError(`${label} SSE idle`, PROVIDER_SSE_IDLE_TIMEOUT_MS);
652
+ }
653
+ } catch (err) {
654
+ clearFirstByteTimer();
655
+ if (signal?.aborted) {
656
+ const reason = signal.reason;
657
+ throw reason instanceof Error ? reason : new Error(`${label} aborted`);
658
+ }
659
+ throw err;
660
+ } finally {
661
+ clearFirstByteTimer();
662
+ if (idleTimer) clearTimeout(idleTimer);
663
+ }
664
+
665
+ let response;
666
+ try {
667
+ response = await streamResult.response;
668
+ } catch (err) {
669
+ if (signal?.aborted) {
670
+ const reason = signal.reason;
671
+ throw reason instanceof Error ? reason : new Error(`${label} aborted`);
672
+ }
673
+ throw err;
674
+ }
675
+ const raw = response?.candidates ? response : (response?.response || response);
676
+ const finishReason = raw?.candidates?.[0]?.finishReason || null;
677
+ assertGeminiStreamCompleted({ sawStreamChunk, finishReason, label });
678
+ return raw;
679
+ }
680
+
360
681
  /**
361
682
  * Convert JSON Schema type string to Gemini SchemaType.
362
683
  * Gemini SDK uses its own enum instead of plain strings.
@@ -836,6 +1157,7 @@ export class GeminiProvider {
836
1157
  async _doSend(messages, model, tools, sendOpts) {
837
1158
  const opts = sendOpts || {};
838
1159
  const signal = opts.signal || null;
1160
+ const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
839
1161
  if (signal?.aborted) {
840
1162
  const reason = signal.reason;
841
1163
  throw reason instanceof Error ? reason : new Error('Gemini request aborted by session close');
@@ -884,7 +1206,7 @@ export class GeminiProvider {
884
1206
  let response;
885
1207
  if (cachedContent) {
886
1208
  const apiKey = this._getApiKey();
887
- const genUrl = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(useModel)}:generateContent?key=${encodeURIComponent(apiKey)}`;
1209
+ const genUrl = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(useModel)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(apiKey)}`;
888
1210
  const cachedPrefixContentCount = Number.isFinite(Number(opts.providerState?.gemini?.cachePrefixContentCount))
889
1211
  ? Math.max(0, Math.min(contents.length, Math.trunc(Number(opts.providerState.gemini.cachePrefixContentCount))))
890
1212
  : 0;
@@ -897,32 +1219,42 @@ export class GeminiProvider {
897
1219
  cachedContent,
898
1220
  };
899
1221
  if (toolConfig) body.toolConfig = toolConfig;
900
- const fetchResult = await runGeminiOperationWithTimeout({
901
- label: 'Gemini REST generateContent total',
902
- timeoutMs: GEMINI_GENERATE_TOTAL_TIMEOUT_MS,
1222
+ response = await runGeminiOperationWithTimeout({
1223
+ label: 'Gemini REST streamGenerateContent total',
1224
+ timeoutMs: PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS,
903
1225
  signal,
904
1226
  run: (totalSignal) => withRetry(
905
- () => runGeminiOperationWithTimeout({
906
- label: 'Gemini REST generateContent first byte',
907
- timeoutMs: GEMINI_FIRST_BYTE_TIMEOUT_MS,
908
- signal: totalSignal,
909
- run: async (opSignal) => {
910
- const res = await fetch(genUrl, {
1227
+ async ({ signal: attemptSignal }) => {
1228
+ try { opts.onStageChange?.('requesting'); } catch {}
1229
+ const openFirstByte = createTimeoutSignal(
1230
+ attemptSignal,
1231
+ GEMINI_FIRST_BYTE_TIMEOUT_MS,
1232
+ 'Gemini REST first byte',
1233
+ );
1234
+ let res;
1235
+ try {
1236
+ res = await fetch(genUrl, {
911
1237
  method: 'POST',
912
1238
  headers: { 'Content-Type': 'application/json' },
913
1239
  body: JSON.stringify(body),
914
- signal: opSignal,
1240
+ signal: openFirstByte.signal,
915
1241
  dispatcher: getLlmDispatcher(),
916
1242
  });
917
- if (!res.ok) {
918
- const text = await res.text().catch(() => '');
919
- const err = new Error(`Gemini REST generateContent ${res.status}: ${text.slice(0, 300)}`);
920
- err.status = res.status;
921
- throw err;
922
- }
923
- return await res.json();
924
- },
925
- }),
1243
+ } finally {
1244
+ openFirstByte.cleanup();
1245
+ }
1246
+ if (!res.ok) {
1247
+ const text = await res.text().catch(() => '');
1248
+ const err = new Error(`Gemini REST streamGenerateContent ${res.status}: ${text.slice(0, 300)}`);
1249
+ err.status = res.status;
1250
+ throw err;
1251
+ }
1252
+ return await consumeGeminiRestStreamResponse(res, {
1253
+ signal: attemptSignal,
1254
+ onStreamDelta,
1255
+ label: 'Gemini REST streamGenerateContent',
1256
+ });
1257
+ },
926
1258
  {
927
1259
  signal: totalSignal,
928
1260
  onRetry: ({ attempt, lastErr }) => {
@@ -932,7 +1264,6 @@ export class GeminiProvider {
932
1264
  },
933
1265
  ),
934
1266
  });
935
- response = fetchResult;
936
1267
  } else {
937
1268
  const genModel = this.genAI.getGenerativeModel({
938
1269
  model: useModel,
@@ -940,17 +1271,42 @@ export class GeminiProvider {
940
1271
  tools: geminiTools,
941
1272
  ...(toolConfig ? { toolConfig } : {}),
942
1273
  });
943
- const result = await runGeminiOperationWithTimeout({
944
- label: 'Gemini generateContent total',
945
- timeoutMs: GEMINI_GENERATE_TOTAL_TIMEOUT_MS,
1274
+ response = await runGeminiOperationWithTimeout({
1275
+ label: 'Gemini streamGenerateContent total',
1276
+ timeoutMs: PROVIDER_NONSTREAM_TOTAL_TIMEOUT_MS,
946
1277
  signal,
947
1278
  run: (totalSignal) => withRetry(
948
- () => runGeminiOperationWithTimeout({
949
- label: 'Gemini generateContent first byte',
950
- timeoutMs: GEMINI_FIRST_BYTE_TIMEOUT_MS,
951
- signal: totalSignal,
952
- run: (opSignal) => genModel.generateContent({ contents }, { signal: opSignal }),
953
- }),
1279
+ async ({ signal: attemptSignal }) => {
1280
+ try { opts.onStageChange?.('requesting'); } catch {}
1281
+ const openFirstByte = createTimeoutSignal(
1282
+ attemptSignal,
1283
+ GEMINI_FIRST_BYTE_TIMEOUT_MS,
1284
+ 'Gemini SDK first byte',
1285
+ );
1286
+ let streamResult;
1287
+ try {
1288
+ try {
1289
+ streamResult = await genModel.generateContentStream(
1290
+ { contents },
1291
+ { signal: openFirstByte.signal },
1292
+ );
1293
+ } catch (err) {
1294
+ if (openFirstByte.signal.aborted) {
1295
+ throw openFirstByte.signal.reason instanceof Error
1296
+ ? openFirstByte.signal.reason
1297
+ : err;
1298
+ }
1299
+ throw err;
1300
+ }
1301
+ } finally {
1302
+ openFirstByte.cleanup();
1303
+ }
1304
+ return await consumeGeminiSdkStream(streamResult, {
1305
+ signal: attemptSignal,
1306
+ onStreamDelta,
1307
+ label: 'Gemini SDK streamGenerateContent',
1308
+ });
1309
+ },
954
1310
  {
955
1311
  signal: totalSignal,
956
1312
  onRetry: ({ attempt, lastErr }) => {
@@ -960,7 +1316,6 @@ export class GeminiProvider {
960
1316
  },
961
1317
  ),
962
1318
  });
963
- response = result.response;
964
1319
  }
965
1320
  writeGeminiCacheTrace({
966
1321
  opts,
@@ -710,11 +710,8 @@ export async function loginOAuth() {
710
710
  url.searchParams.set('plan', 'generic');
711
711
  url.searchParams.set('referrer', 'mixdog');
712
712
  process.stderr.write(`\n[grok-oauth] Open this URL to log in (consent shows as "Grok Build"):\n${url.toString()}\n\n`);
713
- try {
714
- const { exec } = await import('child_process');
715
- const opener = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
716
- exec(`${opener} "${url.toString()}"`, { windowsHide: true });
717
- } catch { /* user opens manually */ }
713
+ const { openInBrowser } = await import('../../../shared/open-url.mjs');
714
+ openInBrowser(url.toString());
718
715
 
719
716
  return new Promise((resolve) => {
720
717
  const timeout = setTimeout(() => { server.close(); resolve(null); }, LOGIN_TIMEOUT_MS);