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.
- package/package.json +1 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
- package/scripts/process-lifecycle-test.mjs +13 -2
- package/scripts/provider-admission-scheduler-test.mjs +4 -5
- package/scripts/provider-contract-test.mjs +91 -33
- package/scripts/provider-toolcall-test.mjs +97 -17
- package/src/repl.mjs +11 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +53 -7
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/shared/memory-snapshot.mjs +45 -36
- package/src/runtime/shared/process-lifecycle.mjs +113 -36
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/tui/dist/index.mjs +31 -0
- 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 {
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
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(
|
|
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
|
-
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
export const GEMINI_GLOBAL_CACHE_MIN_LIVE_MS =
|
|
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) {
|