mixdog 0.9.51 → 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.
Files changed (110) hide show
  1. package/package.json +5 -3
  2. package/scripts/abort-recovery-test.mjs +17 -1
  3. package/scripts/agent-model-liveness-test.mjs +79 -2
  4. package/scripts/anthropic-admission-retry-integration-test.mjs +119 -0
  5. package/scripts/anthropic-transport-policy-test.mjs +466 -0
  6. package/scripts/atomic-lock-tryonce-test.mjs +60 -1
  7. package/scripts/build-tui.mjs +13 -1
  8. package/scripts/channel-daemon-smoke.mjs +630 -10
  9. package/scripts/code-graph-aggregate-cwd-test.mjs +41 -37
  10. package/scripts/code-graph-disk-hit-test.mjs +11 -0
  11. package/scripts/code-graph-root-federation-test.mjs +273 -0
  12. package/scripts/compact-pressure-test.mjs +55 -1
  13. package/scripts/compact-smoke.mjs +80 -0
  14. package/scripts/context-mcp-metering-test.mjs +1350 -19
  15. package/scripts/deferred-tool-loading-test.mjs +17 -0
  16. package/scripts/gemini-provider-test.mjs +1053 -0
  17. package/scripts/interrupted-turn-history-test.mjs +371 -0
  18. package/scripts/lifecycle-api-test.mjs +76 -0
  19. package/scripts/max-output-recovery-test.mjs +55 -0
  20. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  21. package/scripts/memory-pg-recovery-test.mjs +59 -0
  22. package/scripts/openai-oauth-ws-1006-retry-test.mjs +324 -3
  23. package/scripts/process-lifecycle-test.mjs +389 -0
  24. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  25. package/scripts/provider-contract-test.mjs +268 -0
  26. package/scripts/provider-toolcall-test.mjs +320 -1
  27. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  28. package/scripts/resource-admission-test.mjs +789 -0
  29. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  30. package/scripts/steering-drain-buckets-test.mjs +18 -0
  31. package/scripts/toolcall-args-test.mjs +14 -6
  32. package/scripts/tui-transcript-perf-test.mjs +5 -7
  33. package/src/cli.mjs +15 -2
  34. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  35. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  36. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  37. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  38. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  39. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  40. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  41. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  42. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  43. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  44. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  45. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  46. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  47. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  48. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  49. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  50. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  51. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +130 -47
  52. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  53. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +36 -3
  54. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  55. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  56. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  57. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  58. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  59. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +122 -63
  60. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  61. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  62. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  63. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  64. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  65. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  66. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  67. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  68. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  69. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  70. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +20 -4
  71. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  72. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  73. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  74. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  75. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  76. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  77. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  79. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  80. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  81. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  82. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  83. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  84. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  85. package/src/runtime/memory/index.mjs +22 -4
  86. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  87. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  88. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  89. package/src/runtime/shared/atomic-file.mjs +28 -150
  90. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  91. package/src/runtime/shared/process-shutdown.mjs +27 -4
  92. package/src/runtime/shared/resource-admission.mjs +359 -0
  93. package/src/runtime/shared/staged-child-result.mjs +19 -0
  94. package/src/session-runtime/context-status.mjs +38 -27
  95. package/src/session-runtime/lifecycle-api.mjs +26 -6
  96. package/src/session-runtime/provider-request-tools.mjs +72 -0
  97. package/src/session-runtime/runtime-core.mjs +6 -3
  98. package/src/session-runtime/session-turn-api.mjs +5 -4
  99. package/src/session-runtime/tool-catalog.mjs +375 -15
  100. package/src/standalone/agent-tool.mjs +17 -38
  101. package/src/standalone/channel-daemon-client.mjs +200 -38
  102. package/src/standalone/channel-daemon-transport.mjs +136 -9
  103. package/src/standalone/channel-worker.mjs +79 -12
  104. package/src/tui/dist/index.mjs +73 -278
  105. package/src/tui/engine/session-api-ext.mjs +13 -2
  106. package/src/tui/engine/session-api.mjs +1 -1
  107. package/src/tui/engine/turn.mjs +10 -6
  108. package/src/tui/engine.mjs +13 -4
  109. package/src/tui/index.jsx +4 -1
  110. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -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/403 the key
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
- const dispatchWs = (a) => sendViaWebSocket({
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. Gate via shouldFallbackTransport (denies
159
- // 401/403/404/429 + liveTextEmitted/emittedToolCall/unsafeToRetry).
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 sendViaHttpSse({ ...common, auth: a, opts, fetchFn: opts._fetchFn });
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 sendViaHttpSse({ ...common, auth, opts, fetchFn: opts._fetchFn });
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/403 → reload only OpenAI auth and retry once over WS.
187
- // shouldFallbackTransport denies 401/403, so this branch owns
188
- // its own guard.
189
- if ((status === 401 || status === 403) && !unsafeToRetry) {
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 (shouldFallbackTransport(retryErr, { signal: externalSignal, enabled: httpFallbackEnabled })) {
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 (shouldFallbackTransport(err, { signal: externalSignal, enabled: httpFallbackEnabled })) {
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 baseURL = config.baseURL || preset.baseURL;
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
- return this.anthropic.send(messages, model, tools, {
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 p = getProvider(name);
265
- if (p?.constructor?.inputExcludesCache === true) return true;
266
- return String(name || '').toLowerCase().includes('anthropic');
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