mixdog 0.9.53 → 0.9.55
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 +2 -1
- package/scripts/agent-model-liveness-test.mjs +68 -0
- package/scripts/anthropic-transport-policy-test.mjs +260 -0
- package/scripts/desktop-session-bridge-test.mjs +47 -0
- package/scripts/gemini-provider-test.mjs +396 -1
- package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
- package/scripts/interrupted-turn-history-test.mjs +28 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
- package/scripts/pending-completion-drop-test.mjs +160 -4
- package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
- package/scripts/process-lifecycle-test.mjs +18 -4
- package/scripts/prompt-input-parity-test.mjs +145 -0
- 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/scripts/streaming-tail-window-test.mjs +146 -0
- package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
- package/scripts/tui-runtime-load-bench.mjs +45 -0
- package/scripts/tui-store-frame-batch-test.mjs +99 -0
- package/scripts/tui-transcript-perf-test.mjs +367 -2
- package/scripts/write-backpressure-test.mjs +147 -0
- package/src/cli.mjs +5 -5
- package/src/lib/keychain-cjs.cjs +114 -25
- 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 +145 -23
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
- package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
- package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
- package/src/runtime/memory/lib/trace-store.mjs +66 -4
- package/src/runtime/shared/buffered-appender.mjs +83 -4
- package/src/runtime/shared/memory-snapshot.mjs +45 -36
- package/src/runtime/shared/process-lifecycle.mjs +166 -45
- package/src/runtime/shared/process-shutdown.mjs +2 -1
- package/src/session-runtime/model-route-api.mjs +4 -1
- package/src/session-runtime/native-search.mjs +4 -2
- package/src/session-runtime/provider-auth-api.mjs +8 -1
- package/src/session-runtime/provider-models.mjs +8 -3
- package/src/session-runtime/resource-api.mjs +2 -1
- package/src/session-runtime/runtime-core.mjs +32 -0
- package/src/session-runtime/session-turn-api.mjs +1 -0
- package/src/session-runtime/warmup-schedulers.mjs +5 -1
- package/src/standalone/agent-tool.mjs +19 -1
- package/src/tui/App.jsx +10 -4
- package/src/tui/app/text-layout.mjs +9 -1
- package/src/tui/app/transcript-window.mjs +146 -60
- package/src/tui/app/use-mouse-input.mjs +16 -8
- package/src/tui/app/use-transcript-scroll.mjs +71 -19
- package/src/tui/app/use-transcript-window.mjs +21 -10
- package/src/tui/components/PromptInput.jsx +13 -6
- package/src/tui/dist/index.mjs +1094 -232
- package/src/tui/engine/context-state.mjs +31 -31
- package/src/tui/engine/frame-batched-store.mjs +75 -0
- package/src/tui/engine/session-api-ext.mjs +6 -1
- package/src/tui/engine/session-api.mjs +9 -3
- package/src/tui/engine/session-flow.mjs +54 -27
- package/src/tui/engine/turn.mjs +44 -3
- package/src/tui/engine.mjs +515 -36
- package/src/tui/input-editing.mjs +33 -13
- package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
- package/vendor/ink/build/ink.js +8 -16
package/src/lib/keychain-cjs.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { spawnSync } = require('child_process');
|
|
3
|
+
const { spawn, spawnSync } = require('child_process');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const { resolvePluginData } = require('./plugin-paths.cjs');
|
|
@@ -12,16 +12,18 @@ const KEYCHAIN_TIMEOUT_MS = Number(process.env.MIXDOG_KEYCHAIN_TIMEOUT_MS || 150
|
|
|
12
12
|
const POWERSHELL_TIMEOUT_MS = KEYCHAIN_TIMEOUT_MS;
|
|
13
13
|
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
|
-
// getSecret read cache —
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// getSecret read cache — decrypted secrets stay resident for this process by
|
|
16
|
+
// default, avoiding repeated synchronous keychain subprocesses. An explicit
|
|
17
|
+
// finite TTL can bound cross-process staleness; 0 disables the cache.
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
const KEYCHAIN_CACHE_TTL_MS = (() => {
|
|
20
20
|
const value = Number(process.env.MIXDOG_KEYCHAIN_CACHE_TTL_MS);
|
|
21
|
-
return Number.isFinite(value) && value >= 0 ? value :
|
|
21
|
+
return Number.isFinite(value) && value >= 0 ? value : Number.POSITIVE_INFINITY;
|
|
22
22
|
})();
|
|
23
23
|
/** @type {Map<string, {value: string, expiresAt: number}>} */
|
|
24
24
|
const _secretCache = new Map();
|
|
25
|
+
const _cacheGenerations = new Map();
|
|
26
|
+
let _cacheEpoch = 0;
|
|
25
27
|
|
|
26
28
|
function _cacheGet(account) {
|
|
27
29
|
const key = `${SERVICE}\0${account}`;
|
|
@@ -38,17 +40,22 @@ function _cacheSet(account, value) {
|
|
|
38
40
|
if (value == null || KEYCHAIN_CACHE_TTL_MS === 0) return;
|
|
39
41
|
_secretCache.set(`${SERVICE}\0${account}`, {
|
|
40
42
|
value,
|
|
41
|
-
expiresAt:
|
|
43
|
+
expiresAt: KEYCHAIN_CACHE_TTL_MS === Number.POSITIVE_INFINITY
|
|
44
|
+
? Number.POSITIVE_INFINITY
|
|
45
|
+
: Date.now() + KEYCHAIN_CACHE_TTL_MS,
|
|
42
46
|
});
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
function _cacheInvalidate(account) {
|
|
46
50
|
_secretCache.delete(`${SERVICE}\0${account}`);
|
|
51
|
+
_cacheGenerations.set(account, (_cacheGenerations.get(account) || 0) + 1);
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
function invalidateSecretCache(account) {
|
|
50
55
|
if (account == null) {
|
|
51
56
|
_secretCache.clear();
|
|
57
|
+
_cacheGenerations.clear();
|
|
58
|
+
_cacheEpoch += 1;
|
|
52
59
|
return;
|
|
53
60
|
}
|
|
54
61
|
_cacheInvalidate(account);
|
|
@@ -91,6 +98,25 @@ function run(cmd, args, opts) {
|
|
|
91
98
|
|
|
92
99
|
// PS 5.1(powershell.exe) first: pwsh(PS 7) does not auto-load ProtectedData assembly,
|
|
93
100
|
// so DPAPI calls silently fail there. Windows always ships powershell.exe.
|
|
101
|
+
function resolvePowershellExe() {
|
|
102
|
+
const sysRoot = process.env.SystemRoot || process.env.windir;
|
|
103
|
+
if (!sysRoot) {
|
|
104
|
+
throw new Error('[keychain] cannot resolve SystemRoot to locate powershell.exe for DPAPI');
|
|
105
|
+
}
|
|
106
|
+
return path.join(sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function powershellEnv() {
|
|
110
|
+
const env = {};
|
|
111
|
+
for (const key of [
|
|
112
|
+
'SystemRoot', 'windir', 'PATH', 'PATHEXT',
|
|
113
|
+
'TEMP', 'TMP', 'USERPROFILE',
|
|
114
|
+
]) {
|
|
115
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
116
|
+
}
|
|
117
|
+
return env;
|
|
118
|
+
}
|
|
119
|
+
|
|
94
120
|
function powershell(script) {
|
|
95
121
|
// Bound DPAPI PowerShell calls with a timeout: a hung powershell.exe
|
|
96
122
|
// (AV scan stall, profile loader, transient cert chain lookup) would
|
|
@@ -101,26 +127,52 @@ function powershell(script) {
|
|
|
101
127
|
// a PATH lookup, so a shadow `powershell.exe` earlier in PATH cannot hijack
|
|
102
128
|
// secret encryption/decryption. (pwsh/PS7 is intentionally not used: it does
|
|
103
129
|
// not auto-load the ProtectedData assembly, so DPAPI silently fails there.)
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const _psHosts = [path.join(_sysRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')];
|
|
109
|
-
let timedOut = null;
|
|
110
|
-
for (const exe of _psHosts) {
|
|
111
|
-
const r = run(exe, ['-NonInteractive', '-NoProfile', '-Command', script], { timeout: POWERSHELL_TIMEOUT_MS });
|
|
112
|
-
if (r.error && r.error.code === 'ENOENT') continue;
|
|
113
|
-
if (r.error && r.error.code === 'ETIMEDOUT') {
|
|
114
|
-
timedOut = { exe, timeoutMs: POWERSHELL_TIMEOUT_MS };
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
return r;
|
|
130
|
+
const exe = resolvePowershellExe();
|
|
131
|
+
const r = run(exe, ['-NonInteractive', '-NoProfile', '-Command', script], { timeout: POWERSHELL_TIMEOUT_MS });
|
|
132
|
+
if (r.error && r.error.code === 'ETIMEDOUT') {
|
|
133
|
+
throw new Error(`[keychain] PowerShell DPAPI command timed out after ${POWERSHELL_TIMEOUT_MS}ms (host: ${exe})`);
|
|
118
134
|
}
|
|
119
|
-
if (
|
|
120
|
-
throw new Error(`[keychain] PowerShell
|
|
135
|
+
if (r.error && r.error.code === 'ENOENT') {
|
|
136
|
+
throw new Error(`[keychain] PowerShell not found: ${exe}`);
|
|
121
137
|
}
|
|
122
|
-
|
|
123
|
-
|
|
138
|
+
return r;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function powershellAsync(script) {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
let child;
|
|
144
|
+
try {
|
|
145
|
+
child = spawn(
|
|
146
|
+
resolvePowershellExe(),
|
|
147
|
+
['-NonInteractive', '-NoProfile', '-Command', script],
|
|
148
|
+
{
|
|
149
|
+
env: powershellEnv(),
|
|
150
|
+
shell: false,
|
|
151
|
+
windowsHide: true,
|
|
152
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
153
|
+
timeout: POWERSHELL_TIMEOUT_MS,
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
resolve({ status: null, stdout: '', stderr: '', error });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let stdout = '';
|
|
162
|
+
let stderr = '';
|
|
163
|
+
let settled = false;
|
|
164
|
+
child.stdout.setEncoding('utf8');
|
|
165
|
+
child.stderr.setEncoding('utf8');
|
|
166
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
167
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
168
|
+
const finish = (status, error = null) => {
|
|
169
|
+
if (settled) return;
|
|
170
|
+
settled = true;
|
|
171
|
+
resolve({ status, stdout, stderr, error });
|
|
172
|
+
};
|
|
173
|
+
child.once('error', (error) => finish(null, error));
|
|
174
|
+
child.once('close', (status) => finish(status));
|
|
175
|
+
});
|
|
124
176
|
}
|
|
125
177
|
|
|
126
178
|
function secretsDir() {
|
|
@@ -296,6 +348,35 @@ function win32Get(account) {
|
|
|
296
348
|
return out;
|
|
297
349
|
}
|
|
298
350
|
|
|
351
|
+
async function prewarmSecrets() {
|
|
352
|
+
try {
|
|
353
|
+
if (platform() !== 'win32' || KEYCHAIN_CACHE_TTL_MS === 0) return;
|
|
354
|
+
const dir = secretsDir();
|
|
355
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
356
|
+
const epoch = _cacheEpoch;
|
|
357
|
+
await Promise.all(entries
|
|
358
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.dpapi'))
|
|
359
|
+
.map(async (entry) => {
|
|
360
|
+
const account = entry.name.slice(0, -'.dpapi'.length);
|
|
361
|
+
const generation = _cacheGenerations.get(account) || 0;
|
|
362
|
+
try {
|
|
363
|
+
const b64 = (await fs.promises.readFile(path.join(dir, entry.name), 'utf8')).trim();
|
|
364
|
+
if (!b64) return;
|
|
365
|
+
const r = await powershellAsync(`& { ${PS_UNPROTECT} } '${b64}'`);
|
|
366
|
+
const value = (r.stdout || '').trim();
|
|
367
|
+
if (r.error || r.status !== 0 || !value) return;
|
|
368
|
+
if (_cacheEpoch !== epoch || (_cacheGenerations.get(account) || 0) !== generation) return;
|
|
369
|
+
_cacheSet(account, value);
|
|
370
|
+
} catch {
|
|
371
|
+
// Individual corrupt, deleted, or undecryptable entries must
|
|
372
|
+
// not prevent the remaining secrets from warming.
|
|
373
|
+
}
|
|
374
|
+
}));
|
|
375
|
+
} catch {
|
|
376
|
+
// Prewarming is an optional startup optimization and must never fail boot.
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
299
380
|
function win32Set(account, value) {
|
|
300
381
|
const dir = secretsDir();
|
|
301
382
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -367,4 +448,12 @@ function deleteSecret(account) {
|
|
|
367
448
|
}
|
|
368
449
|
}
|
|
369
450
|
|
|
370
|
-
module.exports = {
|
|
451
|
+
module.exports = {
|
|
452
|
+
getSecret,
|
|
453
|
+
setSecret,
|
|
454
|
+
deleteSecret,
|
|
455
|
+
hasSecret,
|
|
456
|
+
invalidateSecretCache,
|
|
457
|
+
prewarmSecrets,
|
|
458
|
+
SERVICE,
|
|
459
|
+
};
|
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) {
|