mixdog 0.9.53 → 0.9.54

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 (34) hide show
  1. package/package.json +1 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/gemini-provider-test.mjs +396 -1
  5. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  6. package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
  7. package/scripts/process-lifecycle-test.mjs +13 -2
  8. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  9. package/scripts/provider-contract-test.mjs +91 -33
  10. package/scripts/provider-toolcall-test.mjs +97 -17
  11. package/src/repl.mjs +11 -0
  12. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
  13. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
  14. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
  15. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
  16. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  17. package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
  18. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
  19. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
  20. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  22. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  23. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
  24. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
  25. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
  26. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
  27. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +53 -7
  28. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  29. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  30. package/src/runtime/shared/memory-snapshot.mjs +45 -36
  31. package/src/runtime/shared/process-lifecycle.mjs +113 -36
  32. package/src/session-runtime/session-turn-api.mjs +1 -0
  33. package/src/tui/dist/index.mjs +31 -0
  34. package/src/tui/engine/turn.mjs +31 -0
@@ -12,7 +12,12 @@ import {
12
12
  applyCompatProviderChatOptions,
13
13
  parseToolCalls,
14
14
  } from '../src/runtime/agent/orchestrator/providers/openai-compat.mjs';
15
- import { consumeCompatChatCompletionStream } from '../src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs';
15
+ import {
16
+ consumeCompatChatCompletionStream,
17
+ consumeCompatResponsesStream,
18
+ } from '../src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs';
19
+ import { useXaiResponsesWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs';
20
+ import { classifyError } from '../src/runtime/agent/orchestrator/providers/retry-classifier.mjs';
16
21
  import { GrokOAuthProvider } from '../src/runtime/agent/orchestrator/providers/grok-oauth.mjs';
17
22
  import { sendViaWebSocket } from '../src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs';
18
23
  import {
@@ -341,13 +346,18 @@ test('Grok OAuth end-to-end HTTP Responses path is hermetic through inner compat
341
346
  },
342
347
  responsesTransport: 'http',
343
348
  });
344
- provider.ensureAuth = async () => ({ access_token: 'fixture-token' });
345
- const inner = provider._ensureInner('fixture-token', 'grok-4.5');
346
- assert.equal(inner.config.preconnect, false, 'Grok seam must propagate to inner compat');
347
- assert.equal(typeof inner.config.preconnectFn, 'function');
348
- inner.client = {
349
- responses: {
350
- create: async () => stream([
349
+ provider.ensureAuth = async () => ({ access_token: 'fixture-token', user_id: 'fixture-user' });
350
+ const ensureInner = provider._ensureInner.bind(provider);
351
+ let capturedInner = null;
352
+ let capturedParams = null;
353
+ provider._ensureInner = (...args) => {
354
+ const inner = ensureInner(...args);
355
+ capturedInner = inner;
356
+ inner.client = {
357
+ responses: {
358
+ create: async (params) => {
359
+ capturedParams = params;
360
+ return stream([
351
361
  { type: 'response.created', response: { id: 'resp_fixture', model: 'grok-4.5' } },
352
362
  { type: 'response.output_text.delta', delta: 'hermetic' },
353
363
  {
@@ -360,20 +370,66 @@ test('Grok OAuth end-to-end HTTP Responses path is hermetic through inner compat
360
370
  usage: { input_tokens: 3, output_tokens: 1 },
361
371
  },
362
372
  },
363
- ]),
364
- },
373
+ ]);
374
+ },
375
+ },
376
+ };
377
+ return inner;
365
378
  };
366
379
 
367
380
  const result = await provider.send(
368
381
  [{ role: 'user', content: 'fixture' }],
369
382
  'grok-4.5',
370
383
  [],
371
- { sessionId: 'hermetic-grok-contract' },
384
+ { sessionId: 'hermetic-grok-contract', iteration: 2 },
372
385
  );
373
386
  assert.equal(result.content, 'hermetic');
374
387
  assert.equal(result.usage.inputTokens, 3);
388
+ assert.equal(capturedInner.config.preconnect, false, 'Grok seam must propagate to inner compat');
389
+ assert.equal(typeof capturedInner.config.preconnectFn, 'function');
390
+ assert.equal(capturedInner.baseURL, 'https://cli-chat-proxy.grok.com/v1');
391
+ assert.equal(capturedInner.config.responsesTransport, 'http');
392
+ assert.equal(capturedInner.defaultHeaders['x-grok-conv-id'], undefined);
393
+ assert.equal(capturedInner.defaultHeaders['x-grok-session-id'], 'hermetic-grok-contract');
394
+ assert.match(capturedInner.defaultHeaders['x-grok-req-id'], /^[0-9a-f-]{36}$/);
395
+ assert.equal(capturedInner.defaultHeaders['x-grok-model-override'], 'grok-4.5');
396
+ assert.equal(capturedInner.defaultHeaders['x-grok-turn-idx'], '2');
397
+ assert.equal(capturedInner.defaultHeaders['x-grok-user-id'], 'fixture-user');
398
+ assert.equal(capturedParams.store, false);
399
+ assert.deepEqual(capturedParams.include, ['reasoning.encrypted_content']);
400
+ assert.equal(result.providerState.xaiResponses.store, false);
401
+ assert.equal(result.providerState.xaiResponses.previousResponseId, null);
402
+ });
403
+
404
+ test('xAI Responses defaults to HTTP and keeps WebSocket behind explicit opt-in', () => {
405
+ assert.equal(useXaiResponsesWebSocket({}, {}), false);
406
+ assert.equal(useXaiResponsesWebSocket({}, { responsesTransport: 'http' }), false);
407
+ assert.equal(useXaiResponsesWebSocket({}, { responsesTransport: 'websocket' }), true);
375
408
  });
376
409
 
410
+ for (const event of [
411
+ { type: 'response.failed', response: { error: { message: 'forbidden' } } },
412
+ { type: 'error', message: 'forbidden' },
413
+ ]) {
414
+ test(`xAI ${event.type} stream event is retryable 500-equivalent without changing other compat labels`, async () => {
415
+ const xaiError = await consumeCompatResponsesStream(stream([event]), {
416
+ label: 'xai:responses',
417
+ parseResponsesToolCalls: () => [],
418
+ responseOutputText: () => '',
419
+ }).then(() => null, (err) => err);
420
+ assert.equal(xaiError.httpStatus, 500);
421
+ assert.equal(classifyError(xaiError), 'transient');
422
+
423
+ const otherError = await consumeCompatResponsesStream(stream([event]), {
424
+ label: 'other-compat',
425
+ parseResponsesToolCalls: () => [],
426
+ responseOutputText: () => '',
427
+ }).then(() => null, (err) => err);
428
+ assert.equal(otherError.httpStatus, 403);
429
+ assert.equal(classifyError(otherError), 'auth');
430
+ });
431
+ }
432
+
377
433
  test('Grok OAuth does not refresh/replay a 401 after visible tool dispatch', async () => {
378
434
  const provider = Object.create(GrokOAuthProvider.prototype);
379
435
  provider.config = { preconnect: false };
@@ -500,26 +556,28 @@ test('xAI safe 401 replay carries completed warmup into the retry', async () =>
500
556
  assert.equal(attempts, 2);
501
557
  });
502
558
 
503
- test('Grok OAuth safe 401 replay carries completed warmup to refreshed xAI inner', async () => {
504
- const provider = Object.create(GrokOAuthProvider.prototype);
505
- provider.config = { preconnect: false };
506
- const warmup = { usage: { inputTokens: 10 } };
507
- let authCalls = 0;
508
- provider.ensureAuth = async ({ forceRefresh = false } = {}) => {
509
- authCalls += 1;
510
- return { access_token: forceRefresh ? 'fresh' : 'stale' };
511
- };
512
- provider._ensureInner = (token) => ({
513
- _doSend: async (_messages, _model, _tools, opts) => {
514
- if (token === 'stale') {
515
- const err = Object.assign(new Error('401'), { httpStatus: 401 });
516
- Object.defineProperty(err, '__warmup', { value: warmup });
517
- throw err;
518
- }
519
- assert.equal(opts._carriedWarmup, warmup);
520
- return { content: 'retried' };
521
- },
559
+ for (const status of [401, 403]) {
560
+ test(`Grok OAuth safe ${status} replay carries completed warmup to refreshed xAI inner`, async () => {
561
+ const provider = Object.create(GrokOAuthProvider.prototype);
562
+ provider.config = { preconnect: false };
563
+ const warmup = { usage: { inputTokens: 10 } };
564
+ let authCalls = 0;
565
+ provider.ensureAuth = async ({ forceRefresh = false } = {}) => {
566
+ authCalls += 1;
567
+ return { access_token: forceRefresh ? 'fresh' : 'stale' };
568
+ };
569
+ provider._ensureInner = (token) => ({
570
+ _doSend: async (_messages, _model, _tools, opts) => {
571
+ if (token === 'stale') {
572
+ const err = Object.assign(new Error(String(status)), { httpStatus: status });
573
+ Object.defineProperty(err, '__warmup', { value: warmup });
574
+ throw err;
575
+ }
576
+ assert.equal(opts._carriedWarmup, warmup);
577
+ return { content: 'retried' };
578
+ },
579
+ });
580
+ assert.equal((await provider.send([], 'grok-4.5', [], {})).content, 'retried');
581
+ assert.equal(authCalls, 2);
522
582
  });
523
- assert.equal((await provider.send([], 'grok-4.5', [], {})).content, 'retried');
524
- assert.equal(authCalls, 2);
525
- });
583
+ }
@@ -833,6 +833,89 @@ test('openai-compat/xai Responses: load_tool history replays as function_call',
833
833
  assert.equal(input.some((item) => item.type === 'function_call_output' && item.call_id === 'call_load_1'), true);
834
834
  });
835
835
 
836
+ test('openai-compat/xai store=false continuation replays the full conversation with encrypted reasoning', () => {
837
+ const encrypted = {
838
+ type: 'reasoning',
839
+ id: 'reasoning_1',
840
+ encrypted_content: 'opaque-ciphertext',
841
+ summary: [],
842
+ };
843
+ const { input, previousResponseId, startIndex } = _toXaiResponsesInputForTest([
844
+ { role: 'user', content: 'call read' },
845
+ {
846
+ role: 'assistant',
847
+ content: '',
848
+ toolCalls: [{ id: 'call_1', name: 'read', arguments: { path: 'a' } }],
849
+ },
850
+ { role: 'tool', toolCallId: 'call_1', content: 'contents' },
851
+ ], {
852
+ xaiResponses: {
853
+ previousResponseId: null,
854
+ responseId: 'resp_unstored',
855
+ store: false,
856
+ encryptedReasoningItems: [encrypted],
857
+ seenMessageCount: 1,
858
+ model: 'grok-4.5',
859
+ },
860
+ }, { model: 'grok-4.5' });
861
+ assert.equal(previousResponseId, null);
862
+ assert.equal(startIndex, 0);
863
+ assert.equal(input[0].role, 'user');
864
+ assert.deepEqual(input[1], encrypted);
865
+ assert.equal(input[2].call_id, 'call_1');
866
+ assert.equal(input[3].call_id, 'call_1');
867
+ });
868
+
869
+ test('openai-compat/xai store=false second tool round retains original system/user and both tool results', () => {
870
+ const firstReasoning = {
871
+ type: 'reasoning',
872
+ id: 'reasoning_1',
873
+ encrypted_content: 'opaque-first',
874
+ summary: [],
875
+ };
876
+ const secondReasoning = {
877
+ type: 'reasoning',
878
+ id: 'reasoning_2',
879
+ encrypted_content: 'opaque-second',
880
+ summary: [],
881
+ };
882
+ const { input, previousResponseId, startIndex } = _toXaiResponsesInputForTest([
883
+ { role: 'system', content: 'original system' },
884
+ { role: 'user', content: 'original user request' },
885
+ { role: 'assistant', content: '', toolCalls: [{ id: 'call_1', name: 'read', arguments: { path: 'a' } }] },
886
+ { role: 'tool', toolCallId: 'call_1', content: 'first tool result' },
887
+ { role: 'assistant', content: '', toolCalls: [{ id: 'call_2', name: 'grep', arguments: { pattern: 'x' } }] },
888
+ { role: 'tool', toolCallId: 'call_2', content: 'second tool result' },
889
+ ], {
890
+ xaiResponses: {
891
+ previousResponseId: null,
892
+ responseId: 'resp_unstored_2',
893
+ store: false,
894
+ encryptedReasoningHistory: [
895
+ { messageIndex: 2, items: [firstReasoning] },
896
+ { messageIndex: 4, items: [secondReasoning] },
897
+ ],
898
+ seenMessageCount: 4,
899
+ model: 'grok-4.5',
900
+ },
901
+ }, { model: 'grok-4.5' });
902
+ assert.equal(previousResponseId, null);
903
+ assert.equal(startIndex, 0);
904
+ assert.equal(input[0].role, 'system');
905
+ assert.equal(input[0].content[0].text, 'original system');
906
+ assert.equal(input[1].role, 'user');
907
+ assert.equal(input[1].content[0].text, 'original user request');
908
+ assert.deepEqual(input.filter((item) => item.type === 'reasoning'), [firstReasoning, secondReasoning]);
909
+ assert.deepEqual(
910
+ input.filter((item) => item.type === 'function_call').map((item) => item.call_id),
911
+ ['call_1', 'call_2'],
912
+ );
913
+ assert.deepEqual(
914
+ input.filter((item) => item.type === 'function_call_output').map((item) => item.output),
915
+ ['first tool result', 'second tool result'],
916
+ );
917
+ });
918
+
836
919
  test('openai-compat/xai Responses: model switch drops prior tool transcript history', () => {
837
920
  const { input, previousResponseId, continuationResetReason } = _toXaiResponsesInputForTest([
838
921
  { role: 'system', content: 'sys' },
@@ -2869,7 +2952,7 @@ test('openai-compat/xai: HTTP pin uses only the injected preconnect seam', async
2869
2952
  }
2870
2953
  });
2871
2954
 
2872
- test('grok-oauth: all Grok models inherit global transport; explicit setting is the escape hatch', () => {
2955
+ test('grok-oauth: every OAuth model is pinned to the CLI proxy over HTTP/SSE', () => {
2873
2956
  const prevOaiTransport = process.env.MIXDOG_OAI_TRANSPORT;
2874
2957
  const prevResponsesTransport = process.env.MIXDOG_GROK_OAUTH_RESPONSES_TRANSPORT;
2875
2958
  const prevGrokTransport = process.env.MIXDOG_GROK_OAUTH_TRANSPORT;
@@ -2879,22 +2962,19 @@ test('grok-oauth: all Grok models inherit global transport; explicit setting is
2879
2962
  delete process.env.MIXDOG_GROK_OAUTH_TRANSPORT;
2880
2963
  const provider = new GrokOAuthProvider({});
2881
2964
 
2882
- const apiInner = provider._ensureInner('tok', 'grok-build-0.1');
2883
- assert.equal(apiInner.config.responsesTransport, undefined);
2884
-
2885
- // No Grok-specific override: proxy-only grok-build now inherits the
2886
- // shared switch too (WS→api.x.ai), no implicit HTTP pin.
2887
- const proxyInner = provider._ensureInner('tok', 'grok-build');
2888
- assert.equal(proxyInner.config.responsesTransport, undefined);
2889
-
2890
- // Escape hatch: an explicit Grok-specific http setting pins proxy-only
2891
- // models back onto the Grok CLI proxy over HTTP.
2892
- process.env.MIXDOG_GROK_OAUTH_RESPONSES_TRANSPORT = 'http';
2893
- const pinnedProvider = new GrokOAuthProvider({});
2894
- const pinnedProxy = pinnedProvider._ensureInner('tok', 'grok-build');
2895
- assert.equal(pinnedProxy.config.responsesTransport, 'http');
2896
- const pinnedApi = pinnedProvider._ensureInner('tok2', 'grok-build-0.1');
2897
- assert.equal(pinnedApi.config.responsesTransport, 'http');
2965
+ for (const model of ['grok-build-0.1', 'grok-build', 'grok-4.5']) {
2966
+ const inner = provider._ensureInner(`tok-${model}`, model);
2967
+ assert.equal(inner.config.responsesTransport, 'http');
2968
+ assert.equal(inner.baseURL, 'https://cli-chat-proxy.grok.com/v1');
2969
+ }
2970
+
2971
+ // OAuth routing is a security boundary: even explicit WS settings
2972
+ // cannot send the session bearer to the fixed api.x.ai WS endpoint.
2973
+ process.env.MIXDOG_GROK_OAUTH_RESPONSES_TRANSPORT = 'websocket';
2974
+ const pinnedProvider = new GrokOAuthProvider({ responsesTransport: 'websocket' });
2975
+ const pinned = pinnedProvider._ensureInner('tok-explicit', 'grok-4.5');
2976
+ assert.equal(pinned.config.responsesTransport, 'http');
2977
+ assert.equal(pinned.baseURL, 'https://cli-chat-proxy.grok.com/v1');
2898
2978
  } finally {
2899
2979
  if (prevOaiTransport == null) delete process.env.MIXDOG_OAI_TRANSPORT;
2900
2980
  else process.env.MIXDOG_OAI_TRANSPORT = prevOaiTransport;
package/src/repl.mjs CHANGED
@@ -185,6 +185,17 @@ export async function runRepl({ provider: providerName, model, toolMode = 'full'
185
185
  stdout.write(chunk);
186
186
  atLineStart = chunk.endsWith('\n');
187
187
  },
188
+ onTextReset: ({ chars } = {}) => {
189
+ const count = Math.max(0, Number(chars) || 0);
190
+ if (!count || !colorEnabled() || printedToolCard) return false;
191
+ const remaining = streamedText.slice(0, Math.max(0, streamedText.length - count));
192
+ eraseStreamedBlock(streamedText);
193
+ streamedText = remaining;
194
+ if (remaining) stdout.write(remaining);
195
+ printedAny = !!remaining;
196
+ atLineStart = !remaining || remaining.endsWith('\n');
197
+ return true;
198
+ },
188
199
  onUsageDelta: (delta) => applyUsageDelta(stats, delta),
189
200
  },
190
201
  );
@@ -76,6 +76,7 @@ import {
76
76
  clampAnthropicThinkingBudget as clampThinkingBudgetTokens,
77
77
  deferredAnthropicTools as sharedDeferredAnthropicTools,
78
78
  requestAnthropicTools as sharedRequestAnthropicTools,
79
+ normalizeAnthropicNonStreamingResponse,
79
80
  resolveAnthropicCacheTtls as resolveCacheTtls,
80
81
  sanitizeAnthropicInputSchema,
81
82
  toAnthropicToolChoice,
@@ -562,6 +563,7 @@ export class AnthropicOAuthProvider {
562
563
  const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
563
564
  const onToolCall = typeof opts.onToolCall === 'function' ? opts.onToolCall : null;
564
565
  const onTextDelta = typeof opts.onTextDelta === 'function' ? opts.onTextDelta : null;
566
+ const onTextReset = typeof opts.onTextReset === 'function' ? opts.onTextReset : null;
565
567
  const externalSignal = opts.signal || null;
566
568
  // Test seam: lets the retry harness drive stream outcomes without a
567
569
  // live OAuth session.
@@ -612,7 +614,7 @@ export class AnthropicOAuthProvider {
612
614
  try { totalSignal.removeEventListener('abort', handler); } catch {}
613
615
  };
614
616
 
615
- const doRequest = async (accessToken, requestSignal = null) => {
617
+ const doRequest = async (accessToken, requestSignal = null, requestBody = body) => {
616
618
  const controller = createAbortController();
617
619
  const fetchStartedAt = Date.now();
618
620
 
@@ -665,7 +667,7 @@ export class AnthropicOAuthProvider {
665
667
  'x-app': 'cli',
666
668
  'Content-Type': 'application/json',
667
669
  },
668
- body: JSON.stringify(body),
670
+ body: JSON.stringify(requestBody),
669
671
  signal: controller.signal,
670
672
  dispatcher: getLlmDispatcher(),
671
673
  });
@@ -707,8 +709,8 @@ export class AnthropicOAuthProvider {
707
709
  // Test seam: injectable request factory for retry-path tests.
708
710
  const doRequestImpl = typeof opts._doRequestFn === 'function' ? opts._doRequestFn : doRequest;
709
711
 
710
- const requestWithRetry = async (accessToken) => withRetry(async ({ signal: attemptSignal }) => {
711
- const result = await doRequestImpl(accessToken, attemptSignal);
712
+ const requestWithRetry = async (accessToken, requestBody = body) => withRetry(async ({ signal: attemptSignal }) => {
713
+ const result = await doRequestImpl(accessToken, attemptSignal, requestBody);
712
714
  const status = Number(result?.response?.status || 0);
713
715
  const transientStatus = classifyError({ httpStatus: status }) === 'transient';
714
716
  if (transientStatus || status === 429) {
@@ -738,8 +740,14 @@ export class AnthropicOAuthProvider {
738
740
  backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
739
741
  retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
740
742
  retryJitterMode: 'positive',
743
+ // Max/Pro OAuth sessions use subscription quota windows. Claude
744
+ // Code fails their 429s immediately rather than waiting through
745
+ // the API-key/PAYG retry budget (which may carry hours-long
746
+ // Retry-After values).
747
+ retry429: false,
741
748
  perAttemptTimeoutMs: requestTimeoutMs,
742
749
  perAttemptLabel: 'Anthropic OAuth initial response',
750
+ provider: 'anthropic',
743
751
  model: useModel,
744
752
  fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
745
753
  onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
@@ -760,6 +768,51 @@ export class AnthropicOAuthProvider {
760
768
  let firstAttemptError = null;
761
769
  let firstAttemptClassifier = null;
762
770
 
771
+ const recoverNonStreaming = async (midState, streamingError, controller) => {
772
+ const exposedChars = Number(midState?.emittedTextChars) || 0;
773
+ if (!onTextReset || exposedChars <= 0
774
+ || midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
775
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
776
+ throw streamingError;
777
+ }
778
+ let resetAccepted = false;
779
+ try {
780
+ resetAccepted = await onTextReset({
781
+ chars: exposedChars,
782
+ reason: 'anthropic-streaming-fallback',
783
+ }) === true;
784
+ } catch {}
785
+ if (!resetAccepted) {
786
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
787
+ throw streamingError;
788
+ }
789
+ try { controller?.abort?.(streamingError); } catch {}
790
+ try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
791
+ let fallback = await requestWithRetry(creds.accessToken, { ...body, stream: false });
792
+ if (fallback.response.status === 401) {
793
+ cleanupCancelHandler(fallback.cancelHandler);
794
+ try { fallback.controller?.abort?.(); } catch {}
795
+ creds = await this.ensureAuth({ forceRefresh: true, reason: '401' });
796
+ fallback = await requestWithRetry(creds.accessToken, { ...body, stream: false });
797
+ }
798
+ if (!fallback.response.ok) {
799
+ const text = await fallback.response.text().catch(() => '');
800
+ cleanupCancelHandler(fallback.cancelHandler);
801
+ try { fallback.controller?.abort?.(); } catch {}
802
+ const fallbackError = new Error(`Anthropic OAuth API ${fallback.response.status}: ${this.scrubTokens(text).slice(0, 200)}`);
803
+ fallbackError.status = fallback.response.status;
804
+ fallbackError.httpStatus = fallback.response.status;
805
+ throw fallbackError;
806
+ }
807
+ try {
808
+ const message = await fallback.response.json();
809
+ return normalizeAnthropicNonStreamingResponse(message, useModel);
810
+ } finally {
811
+ cleanupCancelHandler(fallback.cancelHandler);
812
+ try { fallback.controller?.abort?.('Anthropic non-streaming fallback complete'); } catch {}
813
+ }
814
+ };
815
+
763
816
  try {
764
817
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
765
818
  let response, controller, cancelHandler;
@@ -919,28 +972,12 @@ export class AnthropicOAuthProvider {
919
972
  } catch { /* ignore non-extensible result */ }
920
973
  return result;
921
974
  } catch (err) {
922
- // Live-text invariant: once a non-empty text chunk has been
923
- // relayed to the client (gateway live mode), the rendered output
924
- // cannot be withdrawn and re-issuing would concatenate a second
925
- // attempt. Surface the failure immediately never retry and
926
- // tag the error so upstream layers refuse to retry as well.
975
+ // Acknowledged reset semantics let the owner tombstone this
976
+ // attempt before the full request is restarted non-streaming.
977
+ // Without that acknowledgement, recoverNonStreaming stamps
978
+ // the error unsafe and preserves the no-concatenation rule.
927
979
  if (midState.emittedText) {
928
- try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {}
929
- try { controller?.abort?.(err); } catch { /* best-effort teardown */ }
930
- if (attemptIndex > 0 && firstAttemptError) {
931
- try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
932
- try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
933
- // firstAttemptError is what actually propagates here when
934
- // live text was emitted this attempt — stamp the unsafe
935
- // flags onto IT too, else upstream sees an unmarked error
936
- // and retries, duplicating already-streamed output.
937
- try {
938
- firstAttemptError.liveTextEmitted = true;
939
- firstAttemptError.unsafeToRetry = true;
940
- } catch {}
941
- throw err;
942
- }
943
- throw err;
980
+ return await recoverNonStreaming(midState, err, controller);
944
981
  }
945
982
  // Completed/partial tools and thinking blocks are also replay
946
983
  // boundaries. Stamp the current (latest) error so no upstream
@@ -174,7 +174,10 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
174
174
  content += emit;
175
175
  try { onStreamDelta?.('text'); } catch {}
176
176
  if (onTextDelta) {
177
- if (state) state.emittedText = true;
177
+ if (state) {
178
+ state.emittedText = true;
179
+ state.emittedTextChars = (Number(state.emittedTextChars) || 0) + emit.length;
180
+ }
178
181
  try { onTextDelta(emit); } catch {}
179
182
  }
180
183
  }
@@ -458,7 +461,10 @@ export async function parseSSEStream(response, signal, abortStream, onStreamDelt
458
461
  } else {
459
462
  content += delta.text || '';
460
463
  if (delta.text && onTextDelta) {
461
- if (state) state.emittedText = true;
464
+ if (state) {
465
+ state.emittedText = true;
466
+ state.emittedTextChars = (Number(state.emittedTextChars) || 0) + delta.text.length;
467
+ }
462
468
  try { onTextDelta(delta.text); } catch {}
463
469
  }
464
470
  if (delta.text) {
@@ -45,6 +45,7 @@ import {
45
45
  clampAnthropicThinkingBudget as clampThinkingBudgetTokens,
46
46
  deferredAnthropicTools as sharedDeferredAnthropicTools,
47
47
  requestAnthropicTools as sharedRequestAnthropicTools,
48
+ normalizeAnthropicNonStreamingResponse,
48
49
  resolveAnthropicCacheTtls as resolveCacheTtls,
49
50
  sanitizeAnthropicInputSchema,
50
51
  toAnthropicToolChoice,
@@ -489,6 +490,7 @@ export class AnthropicProvider {
489
490
  const onStreamDelta = typeof opts.onStreamDelta === 'function' ? opts.onStreamDelta : null;
490
491
  const onToolCall = typeof opts.onToolCall === 'function' ? opts.onToolCall : null;
491
492
  const onTextDelta = typeof opts.onTextDelta === 'function' ? opts.onTextDelta : null;
493
+ const onTextReset = typeof opts.onTextReset === 'function' ? opts.onTextReset : null;
492
494
 
493
495
  // No absolute wall-clock cap on streaming generation: a stream still
494
496
  // emitting SSE deltas must not be killed by a fixed total-lifetime timer.
@@ -579,6 +581,48 @@ export class AnthropicProvider {
579
581
  };
580
582
  };
581
583
 
584
+ const recoverNonStreaming = async (midState, streamingError, streamController) => {
585
+ const exposedChars = Number(midState?.emittedTextChars) || 0;
586
+ if (!onTextReset || exposedChars <= 0
587
+ || midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
588
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
589
+ throw streamingError;
590
+ }
591
+ let resetAccepted = false;
592
+ try {
593
+ resetAccepted = await onTextReset({
594
+ chars: exposedChars,
595
+ reason: 'anthropic-streaming-fallback',
596
+ }) === true;
597
+ } catch {}
598
+ if (!resetAccepted) {
599
+ try { streamingError.liveTextEmitted = true; streamingError.unsafeToRetry = true; } catch {}
600
+ throw streamingError;
601
+ }
602
+ try { streamController.abort?.(streamingError); } catch {}
603
+ try { onStageChange?.('requesting', { transport: 'non-streaming-fallback' }); } catch {}
604
+ const nonStreamingParams = { ...params, stream: false };
605
+ const message = await withRetry(
606
+ async ({ signal: attemptSignal }) => this.client.messages.create(nonStreamingParams, {
607
+ signal: attemptSignal,
608
+ ...(betaHeaders ? { headers: betaHeaders } : {}),
609
+ }),
610
+ {
611
+ signal: totalSignal,
612
+ maxAttempts: anthropicMaxAttempts(),
613
+ backoffMs: ANTHROPIC_RETRY_BACKOFF_MS,
614
+ retryJitterRatio: ANTHROPIC_RETRY_JITTER_RATIO,
615
+ retryJitterMode: 'positive',
616
+ perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
617
+ perAttemptLabel: `${this.name} Anthropic non-streaming fallback`,
618
+ provider: 'anthropic',
619
+ model: useModel,
620
+ fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
621
+ },
622
+ );
623
+ return buildReturnFromParse(normalizeAnthropicNonStreamingResponse(message, useModel));
624
+ };
625
+
582
626
  try {
583
627
  for (let attemptIndex = 0; attemptIndex <= MAX_MIDSTREAM_RETRIES; attemptIndex++) {
584
628
  const streamController = createAbortController();
@@ -662,6 +706,7 @@ export class AnthropicProvider {
662
706
  retryJitterMode: 'positive',
663
707
  perAttemptTimeoutMs: anthropicRequestTimeoutMs(),
664
708
  perAttemptLabel: `${this.name} Anthropic streaming response`,
709
+ provider: 'anthropic',
665
710
  model: useModel,
666
711
  fallbackModel: opts._fallbackTriggered ? undefined : opts.fallbackModel,
667
712
  onRetry: ({ attempt, lastErr, delayMs, delayReason }) => {
@@ -740,30 +785,12 @@ export class AnthropicProvider {
740
785
  _fallbackTriggered: true,
741
786
  });
742
787
  }
743
- // Live-text invariant: once a non-empty text chunk has been
744
- // relayed to the client (gateway live mode), the rendered
745
- // output cannot be withdrawn and re-issuing would concatenate
746
- // a second attempt. Surface immediately never retry — and
747
- // tag the error so upstream layers refuse to retry as well.
788
+ // Acknowledged reset semantics let the owner tombstone this
789
+ // attempt before the full request is restarted non-streaming.
790
+ // Without that acknowledgement, recoverNonStreaming stamps
791
+ // the error unsafe and preserves the no-concatenation rule.
748
792
  if (midState.emittedText) {
749
- try { err.liveTextEmitted = true; err.unsafeToRetry = true; } catch {}
750
- try { streamController.abort?.(err); } catch {}
751
- if (attemptIndex > 0 && firstAttemptError) {
752
- try { firstAttemptError.midstreamRetries = attemptIndex; } catch {}
753
- try { firstAttemptError.midstreamClassifier = firstAttemptClassifier; } catch {}
754
- // firstAttemptError (from an earlier retried attempt)
755
- // is what actually propagates when live text was
756
- // emitted this attempt — stamp the unsafe/partial
757
- // flags onto IT too, otherwise upstream sees an
758
- // unmarked error and retries, duplicating streamed
759
- // output.
760
- try {
761
- firstAttemptError.liveTextEmitted = true;
762
- firstAttemptError.unsafeToRetry = true;
763
- } catch {}
764
- throw err;
765
- }
766
- throw err;
793
+ return await recoverNonStreaming(midState, err, streamController);
767
794
  }
768
795
  if (midState.emittedToolCall || midState.partialToolCall || midState.emittedThinking) {
769
796
  try {
@@ -100,10 +100,10 @@ export function _geminiCachePrefixHash({ model, systemInstruction, geminiTools,
100
100
  }));
101
101
  }
102
102
 
103
- // The provider's default explicit-cache TTL is five minutes. A six-minute
104
- // global-cache floor made every freshly-created default cache ineligible for
105
- // cross-session reuse. Ten seconds matches the per-session minimum headroom.
106
- export const GEMINI_GLOBAL_CACHE_MIN_LIVE_MS = 10 * 1000;
103
+ // Keep enough headroom for the provider's 60s first-byte window plus setup
104
+ // overhead. This matches the default five-minute cache's per-session 25%
105
+ // reuse threshold, so a cross-session hit cannot expire while opening.
106
+ export const GEMINI_GLOBAL_CACHE_MIN_LIVE_MS = 75 * 1000;
107
107
  export const GEMINI_GLOBAL_CACHE_MAX_ENTRIES = 128;
108
108
  // Grace window before deleting a superseded cachedContents name (see the
109
109
  // cross-session race note at the delete call site in gemini.mjs). Long enough
@@ -136,6 +136,17 @@ export function _invalidateGeminiCachesForCredentialFingerprint(credentialFinger
136
136
  return removed;
137
137
  }
138
138
 
139
+ export function _invalidateGeminiCacheName(cacheName) {
140
+ if (!cacheName) return 0;
141
+ let removed = 0;
142
+ for (const [key, entry] of geminiGlobalCaches) {
143
+ if (entry?.cacheName !== cacheName) continue;
144
+ geminiGlobalCaches.delete(key);
145
+ removed += 1;
146
+ }
147
+ return removed;
148
+ }
149
+
139
150
  export function _pruneGeminiGlobalCaches(now = Date.now()) {
140
151
  for (const [key, entry] of geminiGlobalCaches) {
141
152
  if (!entry?.cacheName || Number(entry.cacheExpiresAt || 0) <= now) {