mixdog 0.9.50 → 0.9.52
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 +5 -3
- package/scripts/abort-recovery-test.mjs +17 -1
- package/scripts/agent-model-liveness-test.mjs +79 -2
- package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
- package/scripts/anthropic-transport-policy-test.mjs +466 -0
- package/scripts/atomic-lock-tryonce-test.mjs +60 -1
- package/scripts/build-tui.mjs +13 -1
- package/scripts/channel-daemon-smoke.mjs +630 -10
- package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
- package/scripts/code-graph-disk-hit-test.mjs +11 -0
- package/scripts/code-graph-root-federation-test.mjs +273 -0
- package/scripts/compact-pressure-test.mjs +55 -1
- package/scripts/compact-smoke.mjs +80 -0
- package/scripts/context-mcp-metering-test.mjs +1350 -19
- package/scripts/deferred-tool-loading-test.mjs +17 -0
- package/scripts/gemini-provider-test.mjs +1053 -0
- package/scripts/hook-bus-test.mjs +23 -0
- package/scripts/internal-tools-normalization-test.mjs +10 -0
- package/scripts/interrupted-turn-history-test.mjs +371 -0
- package/scripts/lifecycle-api-test.mjs +76 -0
- package/scripts/max-output-recovery-test.mjs +55 -0
- package/scripts/mcp-grace-deferred-test.mjs +89 -13
- package/scripts/memory-pg-recovery-test.mjs +59 -0
- package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
- package/scripts/process-lifecycle-test.mjs +389 -0
- package/scripts/provider-admission-scheduler-test.mjs +582 -0
- package/scripts/provider-contract-test.mjs +268 -0
- package/scripts/provider-toolcall-test.mjs +520 -3
- package/scripts/reactive-compact-persist-smoke.mjs +59 -0
- package/scripts/resource-admission-test.mjs +789 -0
- package/scripts/session-bench-cache-break-test.mjs +102 -0
- package/scripts/session-bench.mjs +101 -42
- package/scripts/shell-failure-diagnostics-test.mjs +73 -4
- package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
- package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
- package/scripts/smoke-loop-failure-summary.mjs +16 -0
- package/scripts/smoke-loop.mjs +2 -7
- package/scripts/steering-drain-buckets-test.mjs +18 -0
- package/scripts/tool-failures.mjs +15 -1
- package/scripts/toolcall-args-test.mjs +14 -6
- package/scripts/tui-transcript-perf-test.mjs +43 -7
- package/scripts/web-fetch-routing-test.mjs +158 -0
- package/src/cli.mjs +15 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
- package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
- package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
- package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
- package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
- package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
- package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
- package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
- package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
- package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
- package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
- package/src/runtime/memory/index.mjs +22 -4
- package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
- package/src/runtime/memory/lib/pg/process.mjs +91 -47
- package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
- package/src/runtime/search/index.mjs +41 -0
- package/src/runtime/search/lib/http-fetch.mjs +154 -0
- package/src/runtime/search/tool-defs.mjs +23 -0
- package/src/runtime/shared/atomic-file.mjs +28 -150
- package/src/runtime/shared/process-lifecycle.mjs +363 -0
- package/src/runtime/shared/process-shutdown.mjs +27 -4
- package/src/runtime/shared/resource-admission.mjs +359 -0
- package/src/runtime/shared/staged-child-result.mjs +19 -0
- package/src/session-runtime/context-status.mjs +38 -27
- package/src/session-runtime/lifecycle-api.mjs +26 -6
- package/src/session-runtime/provider-request-tools.mjs +72 -0
- package/src/session-runtime/runtime-core.mjs +43 -18
- package/src/session-runtime/session-turn-api.mjs +5 -4
- package/src/session-runtime/tool-catalog.mjs +375 -15
- package/src/standalone/agent-tool.mjs +17 -38
- package/src/standalone/channel-daemon-client.mjs +200 -38
- package/src/standalone/channel-daemon-transport.mjs +136 -9
- package/src/standalone/channel-worker.mjs +79 -12
- package/src/standalone/hook-bus/handlers.mjs +4 -0
- package/src/standalone/hook-bus/rules.mjs +7 -1
- package/src/standalone/hook-bus.mjs +10 -2
- package/src/tui/App.jsx +17 -11
- package/src/tui/app/live-spinner-visibility.mjs +20 -0
- package/src/tui/dist/index.mjs +101 -281
- package/src/tui/engine/session-api-ext.mjs +13 -2
- package/src/tui/engine/session-api.mjs +1 -1
- package/src/tui/engine/turn.mjs +10 -6
- package/src/tui/engine.mjs +13 -4
- package/src/tui/index.jsx +4 -1
- package/src/tui/lib/voice-setup.mjs +16 -0
|
@@ -36,7 +36,9 @@ const OPENAI_WS_URL = 'wss://api.openai.com/v1/responses';
|
|
|
36
36
|
const XAI_WS_URL = 'wss://api.x.ai/v1/responses';
|
|
37
37
|
export const WS_IDLE_MS = resolveTimeoutMs(
|
|
38
38
|
'MIXDOG_PROVIDER_WS_IDLE_MS',
|
|
39
|
-
|
|
39
|
+
// Codex keeps its cached connection until the backend's documented
|
|
40
|
+
// 60-minute Responses WebSocket connection limit.
|
|
41
|
+
60 * 60_000,
|
|
40
42
|
{ minMs: 60_000, maxMs: 60 * 60_000 },
|
|
41
43
|
);
|
|
42
44
|
const WS_HANDSHAKE_TIMEOUT_MS = PROVIDER_WS_HANDSHAKE_TIMEOUT_MS;
|
|
@@ -67,6 +69,44 @@ const MAX_POOLED_SOCKETS_PER_KEY = 8;
|
|
|
67
69
|
// lastRequestInput, lastResponseItems, lastInputLen, turnState,
|
|
68
70
|
// closing, ephemeral }
|
|
69
71
|
const _wsPool = new Map();
|
|
72
|
+
let _releaseSequence = 0;
|
|
73
|
+
|
|
74
|
+
function _poolCompatibility(auth, cacheKey) {
|
|
75
|
+
const type = String(auth?.type || 'openai-oauth');
|
|
76
|
+
// Prefer the stable OAuth account id so an access-token refresh can keep
|
|
77
|
+
// using the same connection. If it is unavailable, fail closed to the
|
|
78
|
+
// credential itself; a refresh then opens a new socket rather than risking
|
|
79
|
+
// cross-account continuation.
|
|
80
|
+
const credentialIdentity = auth?.account_id || auth?.apiKey || auth?.access_token || '';
|
|
81
|
+
return {
|
|
82
|
+
authIdentity: createHash('sha256')
|
|
83
|
+
.update(`${type}\0${String(credentialIdentity)}`)
|
|
84
|
+
.digest('hex'),
|
|
85
|
+
cacheKeyIdentity: String(cacheKey || ''),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function _entryCompatible(entry, compatibility) {
|
|
90
|
+
return entry?.authIdentity === compatibility.authIdentity
|
|
91
|
+
&& entry?.cacheKeyIdentity === compatibility.cacheKeyIdentity;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A previous_response_id is valid on the connection that produced it. When
|
|
95
|
+
// concurrency temporarily creates sibling sockets for one pool key, continue
|
|
96
|
+
// on the most recently completed sibling instead of whichever socket happened
|
|
97
|
+
// to be inserted first. This is the closest representation of Codex's
|
|
98
|
+
// turn-local single-connection ownership without weakening the existing
|
|
99
|
+
// full-frame fallback checks.
|
|
100
|
+
function _selectIdleEntry(entries, compatibility) {
|
|
101
|
+
let selected;
|
|
102
|
+
for (const entry of entries || []) {
|
|
103
|
+
if (entry?.busy || !_entryCompatible(entry, compatibility)) continue;
|
|
104
|
+
if (!selected || (entry.releaseSequence || 0) > (selected.releaseSequence || 0)) {
|
|
105
|
+
selected = entry;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return selected;
|
|
109
|
+
}
|
|
70
110
|
|
|
71
111
|
// --- Cache-route probe state (2026-07-04 hunt) -----------------------------
|
|
72
112
|
// CF cookie stickiness (codex chatgpt_cloudflare_cookies.rs:22-55 persists
|
|
@@ -696,6 +736,8 @@ function _openSocket({ auth, sessionToken, turnState, externalSignal, cacheKey,
|
|
|
696
736
|
});
|
|
697
737
|
}
|
|
698
738
|
|
|
739
|
+
let _openSocketImpl = _openSocket;
|
|
740
|
+
|
|
699
741
|
export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders, forceFresh, externalSignal }) {
|
|
700
742
|
const _acqStart = Date.now();
|
|
701
743
|
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
@@ -707,9 +749,16 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
707
749
|
}
|
|
708
750
|
if (poolKey && !forceFresh) {
|
|
709
751
|
const arr = _wsPool.get(poolKey) || [];
|
|
710
|
-
|
|
752
|
+
const compatibility = _poolCompatibility(auth, cacheKey);
|
|
753
|
+
// Prune dead entries and idle sockets from an obsolete auth/cache
|
|
754
|
+
// boundary. Busy incompatible entries are left alone until their owner
|
|
755
|
+
// releases them, but can never be selected by this acquire.
|
|
711
756
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
712
|
-
|
|
757
|
+
const incompatibleIdle = !arr[i].busy && !_entryCompatible(arr[i], compatibility);
|
|
758
|
+
if (!_isOpen(arr[i]) || arr[i].closing || incompatibleIdle) {
|
|
759
|
+
if (incompatibleIdle) {
|
|
760
|
+
try { arr[i].socket.close(1000, 'pool_boundary_changed'); } catch {}
|
|
761
|
+
}
|
|
713
762
|
_clearIdle(arr[i]);
|
|
714
763
|
_clearLiveness(arr[i]);
|
|
715
764
|
arr.splice(i, 1);
|
|
@@ -721,7 +770,7 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
721
770
|
// bound before hand-out; a dead one is evicted and the scan retries the
|
|
722
771
|
// next idle entry so a busy caller is never handed a wedged socket.
|
|
723
772
|
let idle;
|
|
724
|
-
while ((idle = arr
|
|
773
|
+
while ((idle = _selectIdleEntry(arr, compatibility))) {
|
|
725
774
|
_clearIdle(idle);
|
|
726
775
|
_clearLiveness(idle);
|
|
727
776
|
// Reserve the entry BEFORE awaiting the probe: _pingProbe yields the
|
|
@@ -760,7 +809,7 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
760
809
|
process.stderr.write(`[agent-trace] acquire-ephemeral cacheKey=${cacheKey} reason=cap elapsed=${Date.now() - _acqStart}ms\n`);
|
|
761
810
|
}
|
|
762
811
|
const ephSessionToken = _mintSessionToken(cacheKey, auth);
|
|
763
|
-
const { socket, turnState } = await
|
|
812
|
+
const { socket, turnState } = await _openSocketImpl({ auth, sessionToken: ephSessionToken, turnState: null, externalSignal, cacheKey, codexHeaders });
|
|
764
813
|
// Drain-complete fence: same invariant as the normal acquire path —
|
|
765
814
|
// if drain fired during the await, do NOT push an ephemeral entry
|
|
766
815
|
// back into the pool.
|
|
@@ -778,6 +827,8 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
778
827
|
lastResponseItems: null,
|
|
779
828
|
lastInputLen: 0,
|
|
780
829
|
lastInputPrefixHash: null,
|
|
830
|
+
releaseSequence: 0,
|
|
831
|
+
...compatibility,
|
|
781
832
|
turnState: turnState || null,
|
|
782
833
|
closing: false,
|
|
783
834
|
ephemeral: true,
|
|
@@ -797,10 +848,17 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
797
848
|
// returns "No tool output found for function call …". turnState only
|
|
798
849
|
// propagates within a single entry across its own iterations.
|
|
799
850
|
const sessionToken = _mintSessionToken(cacheKey, auth);
|
|
851
|
+
const compatibility = _poolCompatibility(auth, cacheKey);
|
|
800
852
|
if (process.env.MIXDOG_DEBUG_AGENT) {
|
|
801
853
|
process.stderr.write(`[agent-trace] acquire-new tokenHash=${createHash('sha256').update(String(sessionToken)).digest('hex').slice(0, 8)} elapsed=${Date.now() - _acqStart}ms\n`);
|
|
802
854
|
}
|
|
803
|
-
const { socket, turnState } = await
|
|
855
|
+
const { socket, turnState } = await _openSocketImpl({ auth, sessionToken, turnState: null, externalSignal, cacheKey, codexHeaders });
|
|
856
|
+
// Drain may complete while the normal handshake is awaiting 'open'. Never
|
|
857
|
+
// return or insert that late socket into the already-drained process pool.
|
|
858
|
+
if (_drainComplete) {
|
|
859
|
+
try { socket.close(1000, 'drain-complete'); } catch {}
|
|
860
|
+
throw new Error('WS pool drained — process exiting');
|
|
861
|
+
}
|
|
804
862
|
const entry = {
|
|
805
863
|
socket,
|
|
806
864
|
busy: true,
|
|
@@ -811,6 +869,8 @@ export async function acquireWebSocket({ auth, poolKey, cacheKey, codexHeaders,
|
|
|
811
869
|
lastResponseItems: null,
|
|
812
870
|
lastInputLen: 0,
|
|
813
871
|
lastInputPrefixHash: null,
|
|
872
|
+
releaseSequence: 0,
|
|
873
|
+
...compatibility,
|
|
814
874
|
turnState: turnState || null,
|
|
815
875
|
closing: false,
|
|
816
876
|
ephemeral: false,
|
|
@@ -840,6 +900,7 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
|
|
|
840
900
|
// Mark activity at release, then arm both the idle-close timer and the
|
|
841
901
|
// periodic liveness ping so a socket that dies while pooled is evicted
|
|
842
902
|
// before the next acquire can hand it out.
|
|
903
|
+
entry.releaseSequence = ++_releaseSequence;
|
|
843
904
|
entry.lastAliveAt = Date.now();
|
|
844
905
|
_scheduleIdleClose(poolKey, entry);
|
|
845
906
|
if (WS_PING_ENABLED) _armLiveness(poolKey, entry);
|
|
@@ -848,6 +909,48 @@ export function releaseWebSocket({ entry, poolKey, keep }) {
|
|
|
848
909
|
_setTransportReferenced(entry, false);
|
|
849
910
|
}
|
|
850
911
|
|
|
912
|
+
export function closeOpenaiWsPoolForSession(poolKey, reason = 'session_closed') {
|
|
913
|
+
if (!poolKey) return;
|
|
914
|
+
const entries = _wsPool.get(poolKey);
|
|
915
|
+
if (!entries) return;
|
|
916
|
+
_wsPool.delete(poolKey);
|
|
917
|
+
for (const entry of entries) {
|
|
918
|
+
_clearIdle(entry);
|
|
919
|
+
_clearLiveness(entry);
|
|
920
|
+
try { entry.socket.close(1000, reason); } catch {}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Focused pool lifecycle test seam. Entries still pass through the production
|
|
925
|
+
// release/acquire code; this only avoids a live provider handshake.
|
|
926
|
+
export function _seedWebSocketEntryForTest({ poolKey, auth, cacheKey, entry }) {
|
|
927
|
+
Object.assign(entry, _poolCompatibility(auth, cacheKey));
|
|
928
|
+
if (entry.releaseSequence === undefined) entry.releaseSequence = 0;
|
|
929
|
+
_getPoolArr(poolKey).push(entry);
|
|
930
|
+
return entry;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export function _clearWebSocketPoolForTest() {
|
|
934
|
+
for (const arr of _wsPool.values()) {
|
|
935
|
+
for (const entry of arr) {
|
|
936
|
+
_clearIdle(entry);
|
|
937
|
+
_clearLiveness(entry);
|
|
938
|
+
try { entry.socket.close(1000, 'test_cleanup'); } catch {}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
_wsPool.clear();
|
|
942
|
+
_releaseSequence = 0;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function _setOpenSocketForTest(fn) {
|
|
946
|
+
_openSocketImpl = typeof fn === 'function' ? fn : _openSocket;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export function _resetOpenSocketDrainForTest() {
|
|
950
|
+
_openSocketImpl = _openSocket;
|
|
951
|
+
_drainComplete = false;
|
|
952
|
+
}
|
|
953
|
+
|
|
851
954
|
// Drain-complete fence — set true once _closeAllPooledSockets runs so any
|
|
852
955
|
// in-flight acquire that resumes after drain throws instead of pushing a
|
|
853
956
|
// fresh socket into the cleared pool. Single-set, process-lifetime invariant.
|
|
@@ -871,4 +974,6 @@ export function _closeAllPooledSockets(reason = 'shutdown') {
|
|
|
871
974
|
_wsPool.clear();
|
|
872
975
|
}
|
|
873
976
|
export const drainOpenaiWsPool = _closeAllPooledSockets;
|
|
977
|
+
globalThis.__mixdogCloseProviderConnectionsForSession = closeOpenaiWsPoolForSession;
|
|
978
|
+
globalThis.__mixdogDrainProviderConnections = drainOpenaiWsPool;
|
|
874
979
|
process.on('exit', drainOpenaiWsPool);
|
|
@@ -23,6 +23,7 @@ import { populateHttpStatusFromMessage } from './retry-classifier.mjs';
|
|
|
23
23
|
import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
|
|
24
24
|
import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
|
|
25
25
|
import {
|
|
26
|
+
PROVIDER_FIRST_BYTE_TIMEOUT_MS,
|
|
26
27
|
PROVIDER_WS_INTER_CHUNK_TIMEOUT_MS,
|
|
27
28
|
PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
|
|
28
29
|
PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS,
|
|
@@ -91,8 +92,8 @@ export function _positiveInt(value, fallback = 0) {
|
|
|
91
92
|
export const WS_PRE_RESPONSE_CREATED_MS = (() => {
|
|
92
93
|
const raw = process.env.MIXDOG_PROVIDER_WS_PRE_RESPONSE_CREATED_TIMEOUT_MS;
|
|
93
94
|
const n = Number(raw);
|
|
94
|
-
if (Number.isFinite(n) && n > 0) return Math.min(Math.max(n, 1_000),
|
|
95
|
-
return
|
|
95
|
+
if (Number.isFinite(n) && n > 0) return Math.min(Math.max(n, 1_000), PROVIDER_FIRST_BYTE_TIMEOUT_MS);
|
|
96
|
+
return PROVIDER_FIRST_BYTE_TIMEOUT_MS;
|
|
96
97
|
})();
|
|
97
98
|
// Single inter-chunk idle timer. Resets on EVERY received frame — any frame,
|
|
98
99
|
// including metadata/keepalive, proves the socket is live.
|
|
@@ -866,12 +867,14 @@ export async function _streamResponse({
|
|
|
866
867
|
// Only non-empty reasoning text is model progress. Empty
|
|
867
868
|
// deltas remain transport activity via resetIdle() above.
|
|
868
869
|
if (event.delta) {
|
|
870
|
+
midState.emittedReasoning = true;
|
|
869
871
|
try { onStreamDelta?.('reasoning'); } catch {}
|
|
870
872
|
bumpSemanticIdle();
|
|
871
873
|
}
|
|
872
874
|
break;
|
|
873
875
|
case 'response.output_item.added':
|
|
874
876
|
if (event.item?.type === 'function_call') {
|
|
877
|
+
midState.startedToolCall = true;
|
|
875
878
|
markActiveToolItem(event.item);
|
|
876
879
|
pendingCalls.set(event.item.id || '', {
|
|
877
880
|
name: event.item.name || '',
|
|
@@ -879,9 +882,11 @@ export async function _streamResponse({
|
|
|
879
882
|
});
|
|
880
883
|
_toolInFlight = true;
|
|
881
884
|
} else if (event.item?.type === 'custom_tool_call') {
|
|
885
|
+
midState.startedToolCall = true;
|
|
882
886
|
markActiveToolItem(event.item);
|
|
883
887
|
_toolInFlight = true;
|
|
884
888
|
} else if (event.item?.type === 'tool_search_call') {
|
|
889
|
+
midState.startedToolCall = true;
|
|
885
890
|
markActiveToolItem(event.item);
|
|
886
891
|
// Mark tool_search in-flight at item-added time, same
|
|
887
892
|
// as function_call/custom_tool_call above, so the
|
|
@@ -897,12 +902,14 @@ export async function _streamResponse({
|
|
|
897
902
|
try { onStreamDelta?.(_toolInFlight ? 'tool' : 'semantic'); } catch {}
|
|
898
903
|
break;
|
|
899
904
|
case 'response.function_call_arguments.delta':
|
|
905
|
+
if (event.delta) midState.startedToolCall = true;
|
|
900
906
|
markActiveToolItem(null, event.item_id);
|
|
901
907
|
_toolInFlight = true;
|
|
902
908
|
try { onStreamDelta?.('tool'); } catch {}
|
|
903
909
|
bumpSemanticIdle();
|
|
904
910
|
break;
|
|
905
911
|
case 'response.custom_tool_call_input.delta':
|
|
912
|
+
if (event.delta) midState.startedToolCall = true;
|
|
906
913
|
markActiveToolItem(null, event.item_id);
|
|
907
914
|
_toolInFlight = true;
|
|
908
915
|
try { onStreamDelta?.('tool'); } catch {}
|
|
@@ -1271,6 +1278,7 @@ export async function _streamResponse({
|
|
|
1271
1278
|
// Only non-empty reasoning deltas are model progress;
|
|
1272
1279
|
// empty variants remain transport activity only.
|
|
1273
1280
|
if (event.delta) {
|
|
1281
|
+
midState.emittedReasoning = true;
|
|
1274
1282
|
try { onStreamDelta?.('reasoning'); } catch {}
|
|
1275
1283
|
bumpSemanticIdle();
|
|
1276
1284
|
}
|
|
@@ -1369,21 +1377,9 @@ export async function _streamResponse({
|
|
|
1369
1377
|
}
|
|
1370
1378
|
armPreStreamWatchdog();
|
|
1371
1379
|
armFirstMeaningfulWatchdog();
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
//
|
|
1375
|
-
// flow. Sending a ping every 10s from our side keeps the socket warm.
|
|
1376
|
-
// The interval is unref'd so it never holds the event loop open, and
|
|
1377
|
-
// cleanup() clears it on every terminal path (completed / close /
|
|
1378
|
-
// error / abort / mid-stream retry teardown).
|
|
1379
|
-
keepaliveTimer = setInterval(() => {
|
|
1380
|
-
try {
|
|
1381
|
-
if (socket.readyState !== WebSocket.OPEN) return;
|
|
1382
|
-
if (WS_TRACE_ENABLED) _writeWsLifecycleTrace('ping');
|
|
1383
|
-
socket.ping();
|
|
1384
|
-
} catch {}
|
|
1385
|
-
}, 10_000);
|
|
1386
|
-
try { keepaliveTimer.unref?.(); } catch {}
|
|
1380
|
+
// No proactive client ping: Codex's Responses WebSocket uses the
|
|
1381
|
+
// library default (automatic pong replies, no periodic client ping).
|
|
1382
|
+
// Incoming frames remain bounded by the 300s stream idle timeout.
|
|
1387
1383
|
});
|
|
1388
1384
|
}
|
|
1389
1385
|
|
|
@@ -43,6 +43,53 @@ export function applyOpenAIDirectFastTier(body, model, opts) {
|
|
|
43
43
|
return body;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
function shouldFallbackDirectTransport(err, options) {
|
|
47
|
+
const status = Number(err?.httpStatus || err?.status || 0);
|
|
48
|
+
const unsafeToRetry = err?.liveTextEmitted === true
|
|
49
|
+
|| err?.emittedToolCall === true
|
|
50
|
+
|| err?.unsafeToRetry === true;
|
|
51
|
+
if (unsafeToRetry || options?.signal?.aborted || options?.enabled === false) return false;
|
|
52
|
+
// Public OpenAI can reject the WebSocket upgrade while still supporting
|
|
53
|
+
// the same request over HTTP/SSE. Only the shared transport's explicit
|
|
54
|
+
// handshake marker may turn these application-looking statuses into a
|
|
55
|
+
// one-shot transport fallback.
|
|
56
|
+
if (err?.wsFailurePhase === 'handshake'
|
|
57
|
+
&& err?.wsHttpFallbackEligible === true
|
|
58
|
+
&& [403, 404, 429].includes(status)) {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
// These are application/auth/quota outcomes from the public Responses API,
|
|
62
|
+
// not evidence that WebSocket transport is unhealthy. Reissuing them over
|
|
63
|
+
// HTTP cannot recover and can duplicate an accepted throttled request.
|
|
64
|
+
if (status >= 400 && status < 500) return false;
|
|
65
|
+
return shouldFallbackTransport(err, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function directOpenAiHandshakeErrorPolicy({ status }) {
|
|
69
|
+
if ([403, 404, 429].includes(Number(status))) {
|
|
70
|
+
return { retry: false, httpFallback: true };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const DIRECT_WS_TEST_SEAM_KEYS = Object.freeze([
|
|
76
|
+
'_acquireWithRetryFn',
|
|
77
|
+
'_streamFn',
|
|
78
|
+
'_sendFrameFn',
|
|
79
|
+
'_sleepFn',
|
|
80
|
+
'_sendSpanTraceFn',
|
|
81
|
+
'_agentTraceFn',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
function directWebSocketTestSeams(value) {
|
|
85
|
+
if (!value || typeof value !== 'object') return {};
|
|
86
|
+
const out = {};
|
|
87
|
+
for (const key of DIRECT_WS_TEST_SEAM_KEYS) {
|
|
88
|
+
if (typeof value[key] === 'function') out[key] = value[key];
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
46
93
|
export class OpenAIDirectProvider {
|
|
47
94
|
// input_tokens INCLUDES cached tokens (OpenAI convention). See registry.mjs.
|
|
48
95
|
static inputExcludesCache = false;
|
|
@@ -56,7 +103,7 @@ export class OpenAIDirectProvider {
|
|
|
56
103
|
if (!k) throw new Error('OPENAI_API_KEY not configured (providers.openai.apiKey)');
|
|
57
104
|
return k;
|
|
58
105
|
}
|
|
59
|
-
// Auth-recovery mirror of openai-compat.reloadApiKey: on a 401
|
|
106
|
+
// Auth-recovery mirror of openai-compat.reloadApiKey: on a 401 the key
|
|
60
107
|
// was likely rotated after this provider instance was built, so re-read
|
|
61
108
|
// only OpenAI's environment/keychain sources before the single retry.
|
|
62
109
|
// Returns the fresh key (or null if none) — no client to rebuild here since
|
|
@@ -140,7 +187,18 @@ export class OpenAIDirectProvider {
|
|
|
140
187
|
iteration,
|
|
141
188
|
useModel,
|
|
142
189
|
};
|
|
143
|
-
|
|
190
|
+
// Keep the same deterministic transport seams as openai-oauth. Besides
|
|
191
|
+
// making the direct path regression-testable without a live request,
|
|
192
|
+
// this lets embedded callers supply their already-instrumented
|
|
193
|
+
// Responses transports without changing the public request envelope.
|
|
194
|
+
const sendWs = typeof opts._sendViaWebSocketFn === 'function'
|
|
195
|
+
? opts._sendViaWebSocketFn
|
|
196
|
+
: sendViaWebSocket;
|
|
197
|
+
const sendHttp = typeof opts._sendViaHttpSseFn === 'function'
|
|
198
|
+
? opts._sendViaHttpSseFn
|
|
199
|
+
: sendViaHttpSse;
|
|
200
|
+
const wsTestSeams = directWebSocketTestSeams(opts._webSocketTestSeams);
|
|
201
|
+
const dispatchWs = (a) => sendWs({
|
|
144
202
|
...common,
|
|
145
203
|
auth: a,
|
|
146
204
|
sendOpts: opts,
|
|
@@ -151,12 +209,18 @@ export class OpenAIDirectProvider {
|
|
|
151
209
|
// the OAuth/Codex handshake headers. Direct API-key auth pins its own
|
|
152
210
|
// provider so it stays on the public (non-Codex) envelope.
|
|
153
211
|
traceProvider: 'openai-direct',
|
|
212
|
+
// Narrow deterministic seams for transport-policy regression tests.
|
|
213
|
+
// Production callers never set this object.
|
|
214
|
+
...wsTestSeams,
|
|
215
|
+
// Mandatory and deliberately applied AFTER sanitized test seams:
|
|
216
|
+
// no caller can disable or invert direct handshake provenance.
|
|
217
|
+
handshakeErrorPolicy: directOpenAiHandshakeErrorPolicy,
|
|
154
218
|
});
|
|
155
219
|
// WS→HTTP/SSE fallback mirrors the openai-oauth wrapper: the shared
|
|
156
220
|
// HTTP transport now accepts auth.type==='openai-direct' (public
|
|
157
221
|
// Responses endpoint + Bearer <apiKey>), so the api-key provider gets
|
|
158
|
-
// the same envelope.
|
|
159
|
-
//
|
|
222
|
+
// the same envelope. The direct gate denies every application 4xx and
|
|
223
|
+
// any liveTextEmitted/emittedToolCall/unsafeToRetry outcome.
|
|
160
224
|
const transportPolicy = resolveOpenAiTransportPolicy();
|
|
161
225
|
const httpFallbackEnabled = transportPolicy.allowHttpFallback
|
|
162
226
|
&& _envFlag('MIXDOG_OPENAI_HTTP_FALLBACK', true);
|
|
@@ -164,7 +228,7 @@ export class OpenAIDirectProvider {
|
|
|
164
228
|
if (!process.env.MIXDOG_QUIET_PROVIDER_LOG) {
|
|
165
229
|
process.stderr.write('[openai-ws] WebSocket unhealthy; falling back to HTTP/SSE\n');
|
|
166
230
|
}
|
|
167
|
-
return
|
|
231
|
+
return sendHttp({ ...common, auth: a, opts, fetchFn: opts._fetchFn });
|
|
168
232
|
};
|
|
169
233
|
// Transport-policy switch (MIXDOG_OAI_TRANSPORT). 'http-sse' forces the
|
|
170
234
|
// HTTP/SSE transport directly — no WS attempt, so skip the fallback log
|
|
@@ -172,7 +236,7 @@ export class OpenAIDirectProvider {
|
|
|
172
236
|
// ('auto'/'ws-full'/'ws-delta') keep the WS-first path below; ws-full vs
|
|
173
237
|
// ws-delta only affects the delta gate inside openai-ws-delta.mjs.
|
|
174
238
|
if (transportPolicy.transport === 'http') {
|
|
175
|
-
return await
|
|
239
|
+
return await sendHttp({ ...common, auth, opts, fetchFn: opts._fetchFn });
|
|
176
240
|
}
|
|
177
241
|
try {
|
|
178
242
|
return await dispatchWs(auth);
|
|
@@ -183,10 +247,12 @@ export class OpenAIDirectProvider {
|
|
|
183
247
|
const unsafeToRetry = err?.liveTextEmitted === true
|
|
184
248
|
|| err?.emittedToolCall === true
|
|
185
249
|
|| err?.unsafeToRetry === true;
|
|
186
|
-
// (1) 401
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
250
|
+
// (1) 401 → reload only OpenAI auth and retry once over WS.
|
|
251
|
+
// Ordinary 403 is a permission/policy decision, not evidence that
|
|
252
|
+
// an API key rotated. Match the OAuth path and keep it terminal.
|
|
253
|
+
// The direct fallback guard denies auth statuses, so this branch
|
|
254
|
+
// owns the single credential-reload replay.
|
|
255
|
+
if (status === 401 && err?.wsFailurePhase !== 'stream' && !unsafeToRetry) {
|
|
190
256
|
process.stderr.write(`[openai-ws] ${status} — reloading apiKey and retrying once\n`);
|
|
191
257
|
const freshKey = this.reloadApiKey();
|
|
192
258
|
if (freshKey) {
|
|
@@ -194,7 +260,7 @@ export class OpenAIDirectProvider {
|
|
|
194
260
|
try {
|
|
195
261
|
return await dispatchWs(retryAuth);
|
|
196
262
|
} catch (retryErr) {
|
|
197
|
-
if (
|
|
263
|
+
if (shouldFallbackDirectTransport(retryErr, { signal: externalSignal, enabled: httpFallbackEnabled })) {
|
|
198
264
|
return await dispatchHttp(retryAuth);
|
|
199
265
|
}
|
|
200
266
|
throw retryErr;
|
|
@@ -204,7 +270,7 @@ export class OpenAIDirectProvider {
|
|
|
204
270
|
}
|
|
205
271
|
// (2) WS transport failure → HTTP/SSE fallback (predicate handles
|
|
206
272
|
// the safety denies).
|
|
207
|
-
if (
|
|
273
|
+
if (shouldFallbackDirectTransport(err, { signal: externalSignal, enabled: httpFallbackEnabled })) {
|
|
208
274
|
return await dispatchHttp(auth);
|
|
209
275
|
}
|
|
210
276
|
throw err;
|
|
@@ -20,11 +20,44 @@ const OPENCODE_GO_CONTEXT_WINDOWS = Object.freeze({
|
|
|
20
20
|
'glm-5': 202752,
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
function isAnthropicGoModel(model) {
|
|
23
|
+
export function isAnthropicGoModel(model) {
|
|
24
24
|
const id = String(model || '').toLowerCase();
|
|
25
25
|
return ANTHROPIC_MODEL_PREFIXES.some(prefix => id.startsWith(prefix));
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
export function resolveOpenCodeGoBaseURLs(configuredBaseURL) {
|
|
29
|
+
const presetBase = OPENAI_COMPAT_PRESETS['opencode-go'].baseURL;
|
|
30
|
+
const openai = String(configuredBaseURL || presetBase).replace(/\/+$/, '');
|
|
31
|
+
// Anthropic's SDK appends `/v1/messages` itself. Supplying the OpenAI base
|
|
32
|
+
// (`.../v1`) would therefore produce the invalid `.../v1/v1/messages`.
|
|
33
|
+
const anthropic = openai.replace(/\/v1$/i, '');
|
|
34
|
+
return { openai, anthropic };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function openCodeGoEndpointForModel(model, configuredBaseURL) {
|
|
38
|
+
const bases = resolveOpenCodeGoBaseURLs(configuredBaseURL);
|
|
39
|
+
return isAnthropicGoModel(model)
|
|
40
|
+
? `${bases.anthropic}/v1/messages`
|
|
41
|
+
: `${bases.openai}/chat/completions`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeOpenCodeGoResultUsage(result, anthropicRoute) {
|
|
45
|
+
if (!anthropicRoute || !result?.usage) return result;
|
|
46
|
+
const usage = result.usage;
|
|
47
|
+
const input = Number(usage.inputTokens) || 0;
|
|
48
|
+
const cached = Number(usage.cachedTokens) || 0;
|
|
49
|
+
const cacheWrite = Number(usage.cacheWriteTokens) || 0;
|
|
50
|
+
const inclusiveInput = input + cached + cacheWrite;
|
|
51
|
+
return {
|
|
52
|
+
...result,
|
|
53
|
+
usage: {
|
|
54
|
+
...usage,
|
|
55
|
+
inputTokens: inclusiveInput,
|
|
56
|
+
promptTokens: Math.max(Number(usage.promptTokens) || 0, inclusiveInput),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
function opencodeGoContextWindow(_modelId, current = 0) {
|
|
29
62
|
const native = Number(current);
|
|
30
63
|
if (Number.isFinite(native) && native > 0) return native;
|
|
@@ -46,24 +79,29 @@ function opencodeGoReasoningLevels(model, current = null) {
|
|
|
46
79
|
export class OpenCodeGoProvider {
|
|
47
80
|
static inputExcludesCache = false;
|
|
48
81
|
name = 'opencode-go';
|
|
82
|
+
config;
|
|
49
83
|
openai;
|
|
50
84
|
anthropic;
|
|
51
85
|
|
|
52
86
|
constructor(config = {}) {
|
|
87
|
+
// Retain the outer account config for the common provider-admission
|
|
88
|
+
// lane. The delegated transports are intentionally not independently
|
|
89
|
+
// wrapped, so all model-family routes share this one 64-wide account.
|
|
90
|
+
this.config = config;
|
|
53
91
|
const preset = OPENAI_COMPAT_PRESETS['opencode-go'];
|
|
54
|
-
const
|
|
55
|
-
this.openai = new OpenAICompatProvider('opencode-go', { ...config, baseURL });
|
|
92
|
+
const bases = resolveOpenCodeGoBaseURLs(config.baseURL || preset.baseURL);
|
|
93
|
+
this.openai = new OpenAICompatProvider('opencode-go', { ...config, baseURL: bases.openai });
|
|
56
94
|
this.anthropic = new AnthropicProvider({
|
|
57
95
|
...config,
|
|
58
96
|
name: 'opencode-go',
|
|
59
|
-
baseURL,
|
|
97
|
+
baseURL: bases.anthropic,
|
|
60
98
|
disableBetaHeaders: true,
|
|
61
99
|
});
|
|
62
100
|
}
|
|
63
101
|
|
|
64
102
|
async send(messages, model, tools, sendOpts) {
|
|
65
103
|
if (isAnthropicGoModel(model)) {
|
|
66
|
-
|
|
104
|
+
const result = await this.anthropic.send(messages, model, tools, {
|
|
67
105
|
...(sendOpts || {}),
|
|
68
106
|
cacheStrategy: {
|
|
69
107
|
tools: 'none',
|
|
@@ -72,6 +110,7 @@ export class OpenCodeGoProvider {
|
|
|
72
110
|
messages: 'none',
|
|
73
111
|
},
|
|
74
112
|
});
|
|
113
|
+
return normalizeOpenCodeGoResultUsage(result, true);
|
|
75
114
|
}
|
|
76
115
|
return this.openai.send(messages, model, tools, sendOpts);
|
|
77
116
|
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
hasGrokOAuthCredentials,
|
|
6
6
|
} from './oauth-credential-probes.mjs';
|
|
7
7
|
import { refreshCatalog as refreshMetadataCatalog, warmModelMetadataCatalogs } from './model-catalog.mjs';
|
|
8
|
+
import { wrapProviderAdmission } from './admission-scheduler.mjs';
|
|
8
9
|
// OpenAI-compat provider names are self-declared by openai-compat-presets.mjs via
|
|
9
10
|
// OPENAI_COMPAT_PRESETS. No parallel list maintained here.
|
|
10
11
|
const providers = new Map();
|
|
@@ -15,6 +16,7 @@ const providerModulePromises = new Map();
|
|
|
15
16
|
// byte identical to the live one, so lazy-init misses that re-run initProviders
|
|
16
17
|
// don't churn (tear down + rebuild) every live provider instance on every call.
|
|
17
18
|
const signatures = new Map();
|
|
19
|
+
const KNOWN_INPUT_EXCLUDES_CACHE = new Set(['anthropic', 'anthropic-oauth']);
|
|
18
20
|
|
|
19
21
|
// Module-level init serialization. agent-tool.mjs's ensureProvider() already
|
|
20
22
|
// serializes inits per provider on a chain promise, but its gateOnPrior() lets
|
|
@@ -108,9 +110,9 @@ async function loadProviderCtor(name, signal = null) {
|
|
|
108
110
|
|
|
109
111
|
function instantiateProvider(name, Ctor, cfg) {
|
|
110
112
|
if (Object.prototype.hasOwnProperty.call(OPENAI_COMPAT_PRESETS, name) && name !== 'opencode-go') {
|
|
111
|
-
return new Ctor(name, cfg);
|
|
113
|
+
return wrapProviderAdmission(new Ctor(name, cfg), name);
|
|
112
114
|
}
|
|
113
|
-
return new Ctor(cfg);
|
|
115
|
+
return wrapProviderAdmission(new Ctor(cfg), name);
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
export async function initProviders(config, { signal = null } = {}) {
|
|
@@ -232,21 +234,21 @@ export function getProvider(name) {
|
|
|
232
234
|
if (name === 'anthropic-oauth' && hasAnthropicOAuthCredentials()) {
|
|
233
235
|
const Ctor = providerCtors.get('anthropic-oauth');
|
|
234
236
|
if (!Ctor) return undefined;
|
|
235
|
-
const inst = new Ctor({});
|
|
237
|
+
const inst = wrapProviderAdmission(new Ctor({}), name);
|
|
236
238
|
providers.set(name, inst);
|
|
237
239
|
return inst;
|
|
238
240
|
}
|
|
239
241
|
if (name === 'openai-oauth' && hasOpenAIOAuthCredentials()) {
|
|
240
242
|
const Ctor = providerCtors.get('openai-oauth');
|
|
241
243
|
if (!Ctor) return undefined;
|
|
242
|
-
const inst = new Ctor({});
|
|
244
|
+
const inst = wrapProviderAdmission(new Ctor({}), name);
|
|
243
245
|
providers.set(name, inst);
|
|
244
246
|
return inst;
|
|
245
247
|
}
|
|
246
248
|
if (name === 'grok-oauth' && hasGrokOAuthCredentials()) {
|
|
247
249
|
const Ctor = providerCtors.get('grok-oauth');
|
|
248
250
|
if (!Ctor) return undefined;
|
|
249
|
-
const inst = new Ctor({});
|
|
251
|
+
const inst = wrapProviderAdmission(new Ctor({}), name);
|
|
250
252
|
providers.set(name, inst);
|
|
251
253
|
return inst;
|
|
252
254
|
}
|
|
@@ -261,15 +263,54 @@ export function getProvider(name) {
|
|
|
261
263
|
// provider states its own answer — no central regex to keep in sync. Unknown /
|
|
262
264
|
// unregistered providers default to false (the openai/gemini majority).
|
|
263
265
|
export function providerInputExcludesCache(name) {
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
266
|
+
const normalized = String(name || '').toLowerCase();
|
|
267
|
+
// Usage accounting is a pure lookup. In particular, never call
|
|
268
|
+
// getProvider() here: its OAuth miss path probes credentials and may lazily
|
|
269
|
+
// instantiate/register a provider.
|
|
270
|
+
const p = providers.get(normalized);
|
|
271
|
+
if (p?.constructor && typeof p.constructor.inputExcludesCache === 'boolean') {
|
|
272
|
+
return p.constructor.inputExcludesCache;
|
|
273
|
+
}
|
|
274
|
+
// A constructor may already be loaded even when the provider is currently
|
|
275
|
+
// absent during a route/config transition. Preserve its declaration, but
|
|
276
|
+
// never infer usage semantics from an arbitrary provider-name substring.
|
|
277
|
+
const Ctor = providerCtors.get(normalized);
|
|
278
|
+
if (Ctor && typeof Ctor.inputExcludesCache === 'boolean') {
|
|
279
|
+
return Ctor.inputExcludesCache;
|
|
280
|
+
}
|
|
281
|
+
// Built-in usage semantics must also be correct before lazy construction
|
|
282
|
+
// (including disabled providers in a fresh process).
|
|
283
|
+
return KNOWN_INPUT_EXCLUDES_CACHE.has(normalized);
|
|
267
284
|
}
|
|
268
285
|
export function getAllProviders() {
|
|
269
286
|
// Defensive copy — callers must not mutate the live registry or retain
|
|
270
287
|
// stale entries across re-init (initProviders rebuilds the map in place).
|
|
271
288
|
return new Map(providers);
|
|
272
289
|
}
|
|
290
|
+
// Narrow synchronous test seam for the lazy-OAuth boundary. It models a
|
|
291
|
+
// constructor whose module is already loaded while guaranteeing every touched
|
|
292
|
+
// registry entry is restored, even when the assertion callback throws.
|
|
293
|
+
export function _withLoadedProviderCtorForTest(name, Ctor, fn) {
|
|
294
|
+
const hadProvider = providers.has(name);
|
|
295
|
+
const priorProvider = providers.get(name);
|
|
296
|
+
const hadCtor = providerCtors.has(name);
|
|
297
|
+
const priorCtor = providerCtors.get(name);
|
|
298
|
+
const hadSignature = signatures.has(name);
|
|
299
|
+
const priorSignature = signatures.get(name);
|
|
300
|
+
providers.delete(name);
|
|
301
|
+
signatures.delete(name);
|
|
302
|
+
providerCtors.set(name, Ctor);
|
|
303
|
+
try {
|
|
304
|
+
return fn();
|
|
305
|
+
} finally {
|
|
306
|
+
if (hadProvider) providers.set(name, priorProvider);
|
|
307
|
+
else providers.delete(name);
|
|
308
|
+
if (hadCtor) providerCtors.set(name, priorCtor);
|
|
309
|
+
else providerCtors.delete(name);
|
|
310
|
+
if (hadSignature) signatures.set(name, priorSignature);
|
|
311
|
+
else signatures.delete(name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
273
314
|
// Background catalog warm-up. Each provider's listModels() either hits its
|
|
274
315
|
// own cached model list (no-op) or fires a single HTTP refresh. Called from
|
|
275
316
|
// agent.init() after providers are registered so the first agent dispatch call
|