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.
Files changed (134) 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/hook-bus-test.mjs +23 -0
  18. package/scripts/internal-tools-normalization-test.mjs +10 -0
  19. package/scripts/interrupted-turn-history-test.mjs +371 -0
  20. package/scripts/lifecycle-api-test.mjs +76 -0
  21. package/scripts/max-output-recovery-test.mjs +55 -0
  22. package/scripts/mcp-grace-deferred-test.mjs +89 -13
  23. package/scripts/memory-pg-recovery-test.mjs +59 -0
  24. package/scripts/openai-oauth-ws-1006-retry-test.mjs +391 -4
  25. package/scripts/process-lifecycle-test.mjs +389 -0
  26. package/scripts/provider-admission-scheduler-test.mjs +582 -0
  27. package/scripts/provider-contract-test.mjs +268 -0
  28. package/scripts/provider-toolcall-test.mjs +520 -3
  29. package/scripts/reactive-compact-persist-smoke.mjs +59 -0
  30. package/scripts/resource-admission-test.mjs +789 -0
  31. package/scripts/session-bench-cache-break-test.mjs +102 -0
  32. package/scripts/session-bench.mjs +101 -42
  33. package/scripts/shell-failure-diagnostics-test.mjs +73 -4
  34. package/scripts/shell-jobs-windows-hide-test.mjs +1 -1
  35. package/scripts/smoke-loop-failure-summary-test.mjs +38 -0
  36. package/scripts/smoke-loop-failure-summary.mjs +16 -0
  37. package/scripts/smoke-loop.mjs +2 -7
  38. package/scripts/steering-drain-buckets-test.mjs +18 -0
  39. package/scripts/tool-failures.mjs +15 -1
  40. package/scripts/toolcall-args-test.mjs +14 -6
  41. package/scripts/tui-transcript-perf-test.mjs +43 -7
  42. package/scripts/web-fetch-routing-test.mjs +158 -0
  43. package/src/cli.mjs +15 -2
  44. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +26 -0
  45. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +4 -1
  46. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -0
  47. package/src/runtime/agent/orchestrator/internal-tools.mjs +2 -0
  48. package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +331 -0
  49. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +118 -26
  50. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +29 -17
  51. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +136 -38
  52. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +24 -4
  53. package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +554 -42
  54. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +67 -18
  55. package/src/runtime/agent/orchestrator/providers/gemini.mjs +95 -46
  56. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +12 -4
  57. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +54 -14
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +10 -80
  61. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +89 -17
  62. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +93 -26
  63. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +144 -51
  64. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +22 -8
  65. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +74 -1
  66. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +111 -6
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +13 -17
  68. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +78 -12
  69. package/src/runtime/agent/orchestrator/providers/opencode-go.mjs +44 -5
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +49 -8
  71. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +224 -104
  72. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +127 -55
  73. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +99 -32
  74. package/src/runtime/agent/orchestrator/session/context-utils.mjs +17 -1
  75. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +38 -0
  76. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -0
  77. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +69 -37
  78. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +10 -1
  79. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +8 -28
  80. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +6 -7
  81. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +2 -0
  82. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +10 -1
  83. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +42 -1
  84. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +5 -1
  85. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +220 -0
  86. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +24 -4
  87. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +5 -0
  88. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +17 -0
  89. package/src/runtime/agent/orchestrator/session/store.mjs +89 -22
  90. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -12
  91. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +42 -4
  92. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +74 -37
  93. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +223 -2
  94. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +380 -37
  95. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +176 -21
  96. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +14 -0
  97. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +108 -2
  98. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +93 -0
  99. package/src/runtime/agent/orchestrator/tools/code-graph-prewarm-worker.mjs +12 -3
  100. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +124 -14
  101. package/src/runtime/memory/index.mjs +22 -4
  102. package/src/runtime/memory/lib/pg/adapter.mjs +84 -10
  103. package/src/runtime/memory/lib/pg/process.mjs +91 -47
  104. package/src/runtime/memory/lib/pg/supervisor.mjs +50 -13
  105. package/src/runtime/search/index.mjs +41 -0
  106. package/src/runtime/search/lib/http-fetch.mjs +154 -0
  107. package/src/runtime/search/tool-defs.mjs +23 -0
  108. package/src/runtime/shared/atomic-file.mjs +28 -150
  109. package/src/runtime/shared/process-lifecycle.mjs +363 -0
  110. package/src/runtime/shared/process-shutdown.mjs +27 -4
  111. package/src/runtime/shared/resource-admission.mjs +359 -0
  112. package/src/runtime/shared/staged-child-result.mjs +19 -0
  113. package/src/session-runtime/context-status.mjs +38 -27
  114. package/src/session-runtime/lifecycle-api.mjs +26 -6
  115. package/src/session-runtime/provider-request-tools.mjs +72 -0
  116. package/src/session-runtime/runtime-core.mjs +43 -18
  117. package/src/session-runtime/session-turn-api.mjs +5 -4
  118. package/src/session-runtime/tool-catalog.mjs +375 -15
  119. package/src/standalone/agent-tool.mjs +17 -38
  120. package/src/standalone/channel-daemon-client.mjs +200 -38
  121. package/src/standalone/channel-daemon-transport.mjs +136 -9
  122. package/src/standalone/channel-worker.mjs +79 -12
  123. package/src/standalone/hook-bus/handlers.mjs +4 -0
  124. package/src/standalone/hook-bus/rules.mjs +7 -1
  125. package/src/standalone/hook-bus.mjs +10 -2
  126. package/src/tui/App.jsx +17 -11
  127. package/src/tui/app/live-spinner-visibility.mjs +20 -0
  128. package/src/tui/dist/index.mjs +101 -281
  129. package/src/tui/engine/session-api-ext.mjs +13 -2
  130. package/src/tui/engine/session-api.mjs +1 -1
  131. package/src/tui/engine/turn.mjs +10 -6
  132. package/src/tui/engine.mjs +13 -4
  133. package/src/tui/index.jsx +4 -1
  134. package/src/tui/lib/voice-setup.mjs +16 -0
@@ -201,9 +201,10 @@ function cachePath() {
201
201
  return join(getPluginData(), CATALOG_CACHE_FILE);
202
202
  }
203
203
 
204
- async function _loadCatalogImpl() {
204
+ async function _loadCatalogImpl(fetchFn = fetch, force = false) {
205
205
  // Disk cache first
206
206
  try {
207
+ if (force) throw new Error('forced refresh');
207
208
  if (existsSync(cachePath())) {
208
209
  const raw = JSON.parse(readFileSync(cachePath(), 'utf-8'));
209
210
  if (raw?.fetchedAt && (Date.now() - raw.fetchedAt) < CATALOG_TTL_MS && raw.data) {
@@ -215,7 +216,7 @@ async function _loadCatalogImpl() {
215
216
  } catch { /* fall through */ }
216
217
  // Remote fetch
217
218
  try {
218
- const res = await fetch(CATALOG_URL, { signal: AbortSignal.timeout(10_000) });
219
+ const res = await fetchFn(CATALOG_URL, { signal: AbortSignal.timeout(10_000) });
219
220
  if (!res.ok) throw new Error('HTTP ' + res.status);
220
221
  const data = await res.json();
221
222
  try {
@@ -226,14 +227,32 @@ async function _loadCatalogImpl() {
226
227
  return data;
227
228
  } catch (err) {
228
229
  process.stderr.write(`[model-catalog] fetch failed: ${err.message}\n`);
230
+ if (fetchFn !== fetch) {
231
+ _memCache = _memCache || {};
232
+ _memCacheAt = Date.now();
233
+ }
229
234
  return {};
230
235
  }
231
236
  }
232
237
 
233
- async function loadCatalog() {
234
- if (_memCache && (Date.now() - _memCacheAt) < CATALOG_TTL_MS) return _memCache;
238
+ async function _loadCatalogInjected(fetchFn) {
239
+ try {
240
+ const res = await fetchFn(CATALOG_URL, { signal: AbortSignal.timeout(10_000) });
241
+ if (!res.ok) throw new Error('HTTP ' + res.status);
242
+ return await res.json();
243
+ } catch (err) {
244
+ process.stderr.write(`[model-catalog] injected fetch failed: ${err.message}\n`);
245
+ return {};
246
+ }
247
+ }
248
+
249
+ export async function loadCatalog({ fetchFn, force = false } = {}) {
250
+ if (typeof fetchFn === 'function' && fetchFn !== fetch) {
251
+ return _loadCatalogInjected(fetchFn);
252
+ }
253
+ if (!force && _memCache && (Date.now() - _memCacheAt) < CATALOG_TTL_MS) return _memCache;
235
254
  if (_loadPromise) return _loadPromise;
236
- _loadPromise = _loadCatalogImpl().finally(() => { _loadPromise = null; });
255
+ _loadPromise = _loadCatalogImpl(fetchFn, force).finally(() => { _loadPromise = null; });
237
256
  return _loadPromise;
238
257
  }
239
258
 
@@ -255,8 +274,9 @@ let _mdLoadPromise = null;
255
274
  function mdCachePath() {
256
275
  return join(getPluginData(), MODELSDEV_CACHE_FILE);
257
276
  }
258
- async function _loadModelsDevImpl() {
277
+ async function _loadModelsDevImpl(fetchFn = fetch, force = false) {
259
278
  try {
279
+ if (force) throw new Error('forced refresh');
260
280
  if (existsSync(mdCachePath())) {
261
281
  const raw = JSON.parse(readFileSync(mdCachePath(), 'utf-8'));
262
282
  if (raw?.fetchedAt && (Date.now() - raw.fetchedAt) < CATALOG_TTL_MS && raw.data) {
@@ -267,7 +287,7 @@ async function _loadModelsDevImpl() {
267
287
  }
268
288
  } catch { /* fall through to remote */ }
269
289
  try {
270
- const res = await fetch(MODELSDEV_URL, { signal: AbortSignal.timeout(10_000) });
290
+ const res = await fetchFn(MODELSDEV_URL, { signal: AbortSignal.timeout(10_000) });
271
291
  if (!res.ok) throw new Error('HTTP ' + res.status);
272
292
  const data = await res.json();
273
293
  try {
@@ -278,13 +298,30 @@ async function _loadModelsDevImpl() {
278
298
  return data;
279
299
  } catch (err) {
280
300
  process.stderr.write(`[model-catalog] models.dev fetch failed: ${err.message}\n`);
301
+ if (fetchFn !== fetch) {
302
+ _mdCache = _mdCache || {};
303
+ _mdCacheAt = Date.now();
304
+ }
281
305
  return _mdCache || {};
282
306
  }
283
307
  }
284
- export async function loadModelsDevCatalog() {
285
- if (_mdCache && (Date.now() - _mdCacheAt) < CATALOG_TTL_MS) return _mdCache;
308
+ async function _loadModelsDevInjected(fetchFn) {
309
+ try {
310
+ const res = await fetchFn(MODELSDEV_URL, { signal: AbortSignal.timeout(10_000) });
311
+ if (!res.ok) throw new Error('HTTP ' + res.status);
312
+ return await res.json();
313
+ } catch (err) {
314
+ process.stderr.write(`[model-catalog] models.dev injected fetch failed: ${err.message}\n`);
315
+ return {};
316
+ }
317
+ }
318
+ export async function loadModelsDevCatalog({ fetchFn, force = false } = {}) {
319
+ if (typeof fetchFn === 'function' && fetchFn !== fetch) {
320
+ return _loadModelsDevInjected(fetchFn);
321
+ }
322
+ if (!force && _mdCache && (Date.now() - _mdCacheAt) < CATALOG_TTL_MS) return _mdCache;
286
323
  if (_mdLoadPromise) return _mdLoadPromise;
287
- _mdLoadPromise = _loadModelsDevImpl().finally(() => { _mdLoadPromise = null; });
324
+ _mdLoadPromise = _loadModelsDevImpl(fetchFn, force).finally(() => { _mdLoadPromise = null; });
288
325
  return _mdLoadPromise;
289
326
  }
290
327
  function warmModelsDevFromDiskSync() {
@@ -488,11 +525,14 @@ function mergeModelMetadata(base, overlay, opts = {}) {
488
525
  * entries keep their original shape (no metadata) so callers can distinguish
489
526
  * "known in catalog" from "no metadata available".
490
527
  */
491
- export async function enrichModels(models) {
528
+ export async function enrichModels(models, { fetchFn, force = false } = {}) {
492
529
  if (!Array.isArray(models)) return models;
493
- const catalog = await loadCatalog();
530
+ const catalog = await loadCatalog({ fetchFn, force });
531
+ let modelsDevCatalog = _mdCache;
494
532
  if (models.some((m) => _modelsDevProviderId(m?.provider))) {
495
- try { await loadModelsDevCatalog(); } catch { /* optional gap filler */ }
533
+ try {
534
+ modelsDevCatalog = await loadModelsDevCatalog({ fetchFn, force });
535
+ } catch { /* optional gap filler */ }
496
536
  }
497
537
  return models.map(m => {
498
538
  const id = m.id || m.name;
@@ -520,7 +560,7 @@ export async function enrichModels(models) {
520
560
  let meta = entry ? _normalize(entry) : null;
521
561
  const metaFromPricingOverride = entry != null && entry === ov;
522
562
  if (m.provider) {
523
- const row = mappedProvider ? _mdCache?.[mappedProvider]?.models?.[id] : null;
563
+ const row = mappedProvider ? modelsDevCatalog?.[mappedProvider]?.models?.[id] : null;
524
564
  const providerMeta = row ? _normalize(_modelsDevRowToOverride(row)) : null;
525
565
  if (providerMeta) meta = mergeModelMetadata(meta, providerMeta, { preserveBaseCosts: metaFromPricingOverride });
526
566
  const providerNative = providerCachedModelMetadataSync(m.provider, id);
@@ -7,7 +7,7 @@ export const OPENAI_COMPAT_PRESETS = {
7
7
  },
8
8
  xai: {
9
9
  baseURL: 'https://api.x.ai/v1',
10
- defaultModel: 'grok-4.3',
10
+ defaultModel: 'grok-4.5',
11
11
  },
12
12
  // OpenCode Go - low-cost coding-model subscription gateway.
13
13
  'opencode-go': {
@@ -450,9 +450,20 @@ export async function consumeCompatChatCompletionStream(stream, {
450
450
  }
451
451
  }
452
452
  }
453
- if (typeof choice?.delta?.reasoning_content === 'string') {
454
- reasoningContent += choice.delta.reasoning_content;
455
- if (choice.delta.reasoning_content) {
453
+ // DeepSeek/OpenCode use reasoning_content; newer LM Studio builds
454
+ // use reasoning, and some local compatibility shims expose
455
+ // thinking. They are aliases, never concatenate multiple aliases
456
+ // from the same chunk.
457
+ const reasoningDelta = typeof choice?.delta?.reasoning_content === 'string'
458
+ ? choice.delta.reasoning_content
459
+ : typeof choice?.delta?.reasoning === 'string'
460
+ ? choice.delta.reasoning
461
+ : typeof choice?.delta?.thinking === 'string'
462
+ ? choice.delta.thinking
463
+ : null;
464
+ if (reasoningDelta !== null) {
465
+ reasoningContent += reasoningDelta;
466
+ if (reasoningDelta) {
456
467
  reportProgress('reasoning');
457
468
  }
458
469
  }
@@ -106,7 +106,10 @@ export function xaiResponsesCacheRouting(opts, params, rawTools, model) {
106
106
 
107
107
  export function normalizeXaiReasoningEffort(value) {
108
108
  const effort = String(value || '').trim().toLowerCase();
109
- return ['none', 'low', 'medium', 'high'].includes(effort) ? effort : null;
109
+ // Grok 4.5 accepts low/medium/high. Omit unsupported values (notably
110
+ // `none`) to retain xAI's authoritative model default rather than sending
111
+ // a value the API rejects.
112
+ return ['low', 'medium', 'high'].includes(effort) ? effort : null;
110
113
  }
111
114
 
112
115
  export function opencodeGoReasoningEffortValues(modelInfo) {
@@ -420,85 +423,12 @@ function traceXaiCacheLane(opts, payload) {
420
423
  }
421
424
 
422
425
  export async function withXaiResponsesCacheLane({ opts, config, cacheRouting, model, transport, previousResponseId, inputCount, signal }, fn) {
423
- const maxInFlight = xaiResponsesCacheLaneMaxInFlight(opts, config);
424
- if (maxInFlight <= 0) {
425
- const laneMeta = { enabled: false, maxInFlight: 0 };
426
- return { value: await fn(laneMeta), laneMeta };
427
- }
428
- const { key: laneKey, shard, lane } = xaiResponsesCacheLaneKey({ model, cacheRouting, opts, config });
429
- const timeoutMs = xaiResponsesCacheLaneQueueTimeoutMs(opts, config);
430
- const state = getXaiResponsesCacheLaneState(laneKey, maxInFlight);
431
- const queued = state.active >= state.maxInFlight;
432
- if (queued) {
433
- traceXaiCacheLane(opts, {
434
- provider: 'xai',
435
- api: 'responses',
436
- transport,
437
- event: 'queued',
438
- lane_key_hash: traceHash(laneKey),
439
- lane_shard: shard,
440
- lane_shards: Number.isFinite(Number(lane?.shards)) ? Number(lane.shards) : null,
441
- lane_auto: lane?.auto === true,
442
- lane_seed_hash: lane?.seedHash || null,
443
- max_in_flight: maxInFlight,
444
- active: state.active,
445
- queue_depth: state.queue.length,
446
- previous_response_used: !!previousResponseId,
447
- input_count: inputCount,
448
- });
449
- }
450
- const handle = await acquireXaiResponsesCacheLane({ key: laneKey, maxInFlight, signal, timeoutMs });
451
- const laneMeta = {
452
- enabled: true,
453
- laneKeyHash: traceHash(laneKey),
454
- shard,
455
- shards: Number.isFinite(Number(lane?.shards)) ? Number(lane.shards) : null,
456
- auto: lane?.auto === true,
457
- seedHash: lane?.seedHash || null,
458
- maxInFlight,
459
- queued,
460
- waitMs: handle.waitedMs,
461
- activeAfterAcquire: handle.activeCount,
462
- queueDepthAfterAcquire: handle.queueDepth,
463
- };
464
- traceXaiCacheLane(opts, {
465
- provider: 'xai',
466
- api: 'responses',
467
- transport,
468
- event: 'acquired',
469
- lane_key_hash: laneMeta.laneKeyHash,
470
- lane_shard: shard,
471
- lane_shards: laneMeta.shards,
472
- lane_auto: laneMeta.auto,
473
- lane_seed_hash: laneMeta.seedHash,
474
- max_in_flight: maxInFlight,
475
- wait_ms: laneMeta.waitMs,
476
- active: laneMeta.activeAfterAcquire,
477
- queue_depth: laneMeta.queueDepthAfterAcquire,
478
- previous_response_used: !!previousResponseId,
479
- input_count: inputCount,
480
- });
481
- const startedAt = Date.now();
482
- try {
483
- return { value: await fn(laneMeta), laneMeta };
484
- } finally {
485
- handle.release();
486
- traceXaiCacheLane(opts, {
487
- provider: 'xai',
488
- api: 'responses',
489
- transport,
490
- event: 'released',
491
- lane_key_hash: laneMeta.laneKeyHash,
492
- lane_shard: shard,
493
- lane_shards: laneMeta.shards,
494
- lane_auto: laneMeta.auto,
495
- lane_seed_hash: laneMeta.seedHash,
496
- max_in_flight: maxInFlight,
497
- held_ms: Date.now() - startedAt,
498
- previous_response_used: !!previousResponseId,
499
- input_count: inputCount,
500
- });
501
- }
426
+ // Historical prompt-cache lanes formed a second admission queue and could
427
+ // time out while waiting. xAI is now governed exclusively by the common
428
+ // fixed-64 provider/account scheduler, regardless of legacy env/option
429
+ // knobs. Keep this wrapper only as a call-shape compatibility boundary.
430
+ const laneMeta = { enabled: false, maxInFlight: 0 };
431
+ return { value: await fn(laneMeta), laneMeta };
502
432
  }
503
433
 
504
434
  export function deterministicUuidFromKey(key) {
@@ -107,6 +107,72 @@ function assertSafeBaseURL(rawURL, providerName) {
107
107
  // parsers → openai-compat-wire.mjs. Re-exported below for existing importers.
108
108
  export { summarizeTraceMessages, extractCompatCachedTokens } from './openai-compat-trace.mjs';
109
109
  export { parseToolCalls, parseResponsesToolCalls } from './openai-compat-wire.mjs';
110
+
111
+ function normalizeReasoningEffort(value, allowed) {
112
+ const effort = String(value ?? '').trim().toLowerCase();
113
+ return allowed.includes(effort) ? effort : null;
114
+ }
115
+
116
+ // Keep provider extensions isolated: fields accepted by one OpenAI-compatible
117
+ // backend are frequently rejected by another even when the core Chat schema is
118
+ // shared.
119
+ export function applyCompatProviderChatOptions(params, providerName, opts = {}, config = {}, modelInfo = null) {
120
+ if (providerName === 'xai') {
121
+ const reasoningEffort = normalizeXaiReasoningEffort(opts.xaiReasoningEffort
122
+ ?? opts.effort
123
+ ?? config?.reasoningEffort
124
+ ?? process.env.MIXDOG_XAI_REASONING_EFFORT);
125
+ if (reasoningEffort) params.reasoning_effort = reasoningEffort;
126
+ return params;
127
+ }
128
+ if (providerName === 'deepseek') {
129
+ const rawThinking = opts.deepseekThinking
130
+ ?? opts.thinking
131
+ ?? config?.thinking;
132
+ const rawEffort = opts.deepseekReasoningEffort
133
+ ?? opts.effort
134
+ ?? config?.reasoningEffort;
135
+ if (rawThinking !== undefined || rawEffort !== undefined) {
136
+ const disabled = rawThinking === false
137
+ || String(rawThinking?.type ?? rawThinking ?? rawEffort).trim().toLowerCase() === 'disabled'
138
+ || String(rawThinking?.type ?? rawThinking ?? rawEffort).trim().toLowerCase() === 'none';
139
+ params.thinking = { type: disabled ? 'disabled' : 'enabled' };
140
+ if (!disabled) {
141
+ const effort = String(rawEffort ?? '').trim().toLowerCase();
142
+ if (effort === 'max' || effort === 'xhigh') params.reasoning_effort = 'max';
143
+ else if (['low', 'medium', 'high'].includes(effort)) params.reasoning_effort = 'high';
144
+ }
145
+ }
146
+ return params;
147
+ }
148
+ if (providerName === 'ollama') {
149
+ const effort = normalizeReasoningEffort(
150
+ opts.ollamaReasoningEffort ?? opts.effort ?? config?.reasoningEffort,
151
+ ['none', 'low', 'medium', 'high', 'max'],
152
+ );
153
+ if (effort) params.reasoning_effort = effort;
154
+ return params;
155
+ }
156
+ if (providerName === 'lmstudio') {
157
+ const effort = normalizeReasoningEffort(
158
+ opts.lmStudioReasoningEffort ?? opts.effort ?? config?.reasoningEffort,
159
+ ['none', 'low', 'medium', 'high', 'max'],
160
+ );
161
+ if (effort) params.reasoning_effort = effort;
162
+ return params;
163
+ }
164
+ if (providerName === 'opencode-go') {
165
+ const reasoningEffort = normalizeOpencodeGoReasoningEffort(
166
+ opts.effort ?? config?.reasoningEffort,
167
+ modelInfo,
168
+ );
169
+ // OpenCode Go's OpenAI-compatible contract exposes reasoning_effort,
170
+ // not DeepSeek's provider-specific `thinking` extension.
171
+ if (reasoningEffort) params.reasoning_effort = reasoningEffort;
172
+ }
173
+ return params;
174
+ }
175
+
110
176
  export class OpenAICompatProvider {
111
177
  // Chat Completions prompt_tokens is already the total (includes cached).
112
178
  // Covers grok-oauth and all OPENAI_COMPAT_PRESETS. See registry.mjs.
@@ -150,6 +216,11 @@ export class OpenAICompatProvider {
150
216
  fetchOptions: { dispatcher: getLlmDispatcher() },
151
217
  });
152
218
  }
219
+ get _preconnectFn() {
220
+ return typeof this.config?.preconnectFn === 'function'
221
+ ? this.config.preconnectFn
222
+ : preconnect;
223
+ }
153
224
  reloadApiKey() {
154
225
  try {
155
226
  const preset = PRESETS[this.name];
@@ -174,7 +245,13 @@ export class OpenAICompatProvider {
174
245
  try {
175
246
  return await this._doSend(messages, model, tools, sendOpts);
176
247
  } catch (err) {
177
- if (err.message && (err.message.includes('401') || err.message.includes('403'))) {
248
+ const structuredStatus = [err?.status, err?.httpStatus, err?.response?.status]
249
+ .map(value => Number(value))
250
+ .find(value => Number.isFinite(value) && value > 0) || 0;
251
+ const status = structuredStatus > 0
252
+ ? structuredStatus
253
+ : (/\b401\b/.test(String(err?.message || '')) ? 401 : 0);
254
+ if (status === 401) {
178
255
  if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
179
256
  throw err;
180
257
  }
@@ -191,7 +268,11 @@ export class OpenAICompatProvider {
191
268
  // Re-warm a kept-alive socket to the provider origin before the turn so
192
269
  // the request hot path lands on a live socket instead of paying a cold
193
270
  // TLS handshake after an idle gap. Fire-and-forget; never awaited.
194
- preconnect(this.baseURL);
271
+ // Tests/local callers can disable this or inject a fail-closed seam;
272
+ // production retains the shared preconnect by default.
273
+ if (this.config?.preconnect !== false) {
274
+ this._preconnectFn(this.baseURL);
275
+ }
195
276
  if (this.name === 'xai' && useXaiResponsesApi(opts, this.config)) {
196
277
  // Shared Responses transport switch (MIXDOG_OAI_TRANSPORT), capability-
197
278
  // gated for xAI/Grok. Provider-local HTTP pins still win: Grok
@@ -259,20 +340,7 @@ export class OpenAICompatProvider {
259
340
  if (tools?.length) {
260
341
  params.tools = toOpenAITools(tools);
261
342
  }
262
- if (this.name === 'xai') {
263
- const reasoningEffort = normalizeXaiReasoningEffort(opts.xaiReasoningEffort
264
- ?? opts.effort
265
- ?? this.config?.reasoningEffort
266
- ?? process.env.MIXDOG_XAI_REASONING_EFFORT);
267
- if (reasoningEffort) params.reasoning_effort = reasoningEffort;
268
- }
269
- if (this.name === 'opencode-go') {
270
- const reasoningEffort = normalizeOpencodeGoReasoningEffort(opts.effort ?? this.config?.reasoningEffort, modelInfo);
271
- if (reasoningEffort) {
272
- params.reasoning_effort = reasoningEffort;
273
- params.thinking = { type: 'enabled' };
274
- }
275
- }
343
+ applyCompatProviderChatOptions(params, this.name, opts, this.config, modelInfo);
276
344
  // Streaming (params.stream = true is always set below): no absolute
277
345
  // wall-clock cap on a healthy stream. A fixed total-lifetime timer
278
346
  // false-aborts live long-reasoning turns that are still emitting SSE
@@ -401,7 +469,11 @@ export class OpenAICompatProvider {
401
469
  // Capture provider reasoning_content so loop.mjs can attach it to the
402
470
  // assistant message and echo it back next turn for providers that
403
471
  // require or benefit from that official multi-turn shape.
404
- const capturesReasoningContent = this.name === 'deepseek' || this.name === 'xai' || replaysReasoningContent;
472
+ const capturesReasoningContent = this.name === 'deepseek'
473
+ || this.name === 'xai'
474
+ || this.name === 'ollama'
475
+ || this.name === 'lmstudio'
476
+ || replaysReasoningContent;
405
477
  const reasoningContent = (capturesReasoningContent && typeof assembled.reasoningContent === 'string')
406
478
  ? assembled.reasoningContent
407
479
  : null;
@@ -13,6 +13,7 @@ import {
13
13
  traceAgentUsage,
14
14
  } from '../agent-trace.mjs';
15
15
  import {
16
+ PROVIDER_FIRST_BYTE_TIMEOUT_MS,
16
17
  PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
17
18
  PROVIDER_SEMANTIC_IDLE_TIMEOUT_MS,
18
19
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
@@ -20,7 +21,12 @@ import {
20
21
  createTimeoutSignal,
21
22
  createPassthroughSignal,
22
23
  } from '../stall-policy.mjs';
23
- import { populateHttpStatusFromMessage, shouldFallbackTransport } from './retry-classifier.mjs';
24
+ import {
25
+ jitterDelayMs,
26
+ populateHttpStatusFromMessage,
27
+ shouldFallbackTransport,
28
+ sleepWithAbort,
29
+ } from './retry-classifier.mjs';
24
30
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
25
31
  import { makeInvalidToolArgsMarker } from './openai-compat-stream.mjs';
26
32
  import { createLeakGuard, createToolCallDedupe, dedupeToolCallList } from './anthropic-leaked-toolcall.mjs';
@@ -34,6 +40,9 @@ import { createActiveToolItemTracker } from './tool-stream-state.mjs';
34
40
  // mirrors it so OpenAIDirectProvider can fall back off WebSocket like
35
41
  // openai-oauth. Same Responses SSE wire format, only endpoint + auth differ.
36
42
  const OPENAI_DIRECT_RESPONSES_URL = 'https://api.openai.com/v1/responses';
43
+ const CODEX_REQUEST_MAX_RETRIES = 4;
44
+ const CODEX_REQUEST_BACKOFF_MS = Object.freeze([200, 400, 800, 1600]);
45
+ const CODEX_RETRY_JITTER_RATIO = 0.1;
37
46
 
38
47
  export function _envFlag(name, fallback = true) {
39
48
  const raw = process.env[name];
@@ -187,6 +196,7 @@ export async function sendViaHttpSse({
187
196
  iteration,
188
197
  useModel,
189
198
  fetchFn = fetch,
199
+ _sleepFn,
190
200
  } = {}) {
191
201
  // P1 audit fix: no fixed wall-clock total cap on the HTTP/SSE fallback
192
202
  // stream. The old createTimeoutSignal(..., PROVIDER_GENERATE_TOTAL_TIMEOUT_MS)
@@ -202,31 +212,60 @@ export async function sendViaHttpSse({
202
212
  // one still aborts, and
203
213
  // (c) externalSignal (client disconnect / replaced-by-newer-request).
204
214
  const totalTimeout = createPassthroughSignal(externalSignal);
205
- const headerTimeout = createTimeoutSignal(
206
- totalTimeout.signal,
207
- PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
208
- 'OpenAI OAuth HTTP fallback initial response',
209
- );
210
215
  const headers = _buildOpenAIHttpFallbackHeaders({ auth, cacheKey });
211
216
  const fetchStartedAt = Date.now();
212
217
  const responsesUrl = auth?.type === 'openai-direct'
213
218
  ? OPENAI_DIRECT_RESPONSES_URL
214
219
  : CODEX_RESPONSES_URL;
215
220
  let response;
216
- try {
217
- try { onStageChange?.('requesting'); } catch {}
218
- response = await fetchFn(responsesUrl, {
219
- method: 'POST',
220
- headers,
221
- body: JSON.stringify(body),
222
- signal: headerTimeout.signal,
223
- dispatcher: getLlmDispatcher(),
224
- });
225
- } catch (err) {
226
- if (headerTimeout.signal?.aborted && headerTimeout.signal.reason instanceof Error) throw headerTimeout.signal.reason;
227
- throw err;
228
- } finally {
229
- headerTimeout.cleanup();
221
+ for (let attempt = 0; attempt <= CODEX_REQUEST_MAX_RETRIES; attempt++) {
222
+ const headerTimeout = createTimeoutSignal(
223
+ totalTimeout.signal,
224
+ PROVIDER_HTTP_RESPONSE_TIMEOUT_MS,
225
+ 'OpenAI OAuth HTTP fallback initial response',
226
+ );
227
+ let requestError = null;
228
+ try {
229
+ try { onStageChange?.('requesting'); } catch {}
230
+ response = await fetchFn(responsesUrl, {
231
+ method: 'POST',
232
+ headers,
233
+ body: JSON.stringify(body),
234
+ signal: headerTimeout.signal,
235
+ dispatcher: getLlmDispatcher(),
236
+ });
237
+ } catch (err) {
238
+ requestError = headerTimeout.signal?.aborted
239
+ && headerTimeout.signal.reason instanceof Error
240
+ ? headerTimeout.signal.reason
241
+ : err;
242
+ } finally {
243
+ headerTimeout.cleanup();
244
+ }
245
+
246
+ const retryableStatus = response && response.status >= 500 && response.status <= 599;
247
+ const retryableTransport = !response && requestError
248
+ && !externalSignal?.aborted
249
+ && !totalTimeout.signal?.aborted;
250
+ if (attempt < CODEX_REQUEST_MAX_RETRIES && (retryableStatus || retryableTransport)) {
251
+ // A non-success response has not exposed any streamed output. Drain
252
+ // its body before reissuing so the dispatcher can reuse the socket.
253
+ if (response) await response.arrayBuffer().catch(() => {});
254
+ const raw = CODEX_REQUEST_BACKOFF_MS[attempt];
255
+ await sleepWithAbort(
256
+ jitterDelayMs(raw, CODEX_RETRY_JITTER_RATIO),
257
+ externalSignal,
258
+ _sleepFn,
259
+ 'OpenAI OAuth HTTP request retry backoff aborted',
260
+ );
261
+ response = undefined;
262
+ continue;
263
+ }
264
+ if (requestError) {
265
+ totalTimeout.cleanup();
266
+ throw requestError;
267
+ }
268
+ break;
230
269
  }
231
270
 
232
271
  traceAgentFetch({
@@ -292,6 +331,30 @@ export async function sendViaHttpSse({
292
331
  // deltas then goes silent trips a short, named terminal failure instead of
293
332
  // hanging until the 30-min agent watchdog. Disablable via the shared env.
294
333
  let _semanticIdleTimer = null;
334
+ let _firstServerEventTimer = null;
335
+ const firstServerEventOverrideMs = Number(opts?._firstServerEventTimeoutMs);
336
+ const firstServerEventMs = Number.isFinite(firstServerEventOverrideMs) && firstServerEventOverrideMs > 0
337
+ ? firstServerEventOverrideMs
338
+ : PROVIDER_FIRST_BYTE_TIMEOUT_MS;
339
+ const _clearFirstServerEvent = () => {
340
+ if (_firstServerEventTimer) {
341
+ clearTimeout(_firstServerEventTimer);
342
+ _firstServerEventTimer = null;
343
+ }
344
+ };
345
+ const _armFirstServerEvent = () => {
346
+ if (!(firstServerEventMs > 0)) return;
347
+ _clearFirstServerEvent();
348
+ _firstServerEventTimer = setTimeout(() => {
349
+ const err = new Error(`OpenAI OAuth HTTP fallback first server event timed out after ${firstServerEventMs}ms`);
350
+ err.code = 'EPROVIDERTIMEOUT';
351
+ err.firstByteTimeout = true;
352
+ _streamAbortReason = err;
353
+ try { reader.cancel(err).catch(() => {}); } catch {}
354
+ _rejectPendingRead(err);
355
+ }, firstServerEventMs);
356
+ try { _firstServerEventTimer.unref?.(); } catch {}
357
+ };
295
358
  const _clearSemanticIdle = () => {
296
359
  if (_semanticIdleTimer) { clearTimeout(_semanticIdleTimer); _semanticIdleTimer = null; }
297
360
  };
@@ -508,6 +571,11 @@ export async function sendViaHttpSse({
508
571
  };
509
572
  const handleEvent = (event) => {
510
573
  if (!event || typeof event.type !== 'string') return;
574
+ _clearFirstServerEvent();
575
+ // Once any real SSE server event arrives, the fixed initial deadline is
576
+ // satisfied and semantic-idle ownership begins. meaningful() below may
577
+ // immediately re-arm it for a semantic event.
578
+ _armSemanticIdle();
511
579
  switch (event.type) {
512
580
  case 'response.created':
513
581
  if (event.response?.model) model = event.response.model;
@@ -783,12 +851,10 @@ export async function sendViaHttpSse({
783
851
  };
784
852
 
785
853
  try {
786
- // Arm the idle watchdog BEFORE the first read: a 200 response with an
787
- // open-but-silent body (no SSE events at all) previously hung on bare
788
- // reader.read() until the outer agent watchdog (CC/codex/opencode all
789
- // bound the first read). meaningful() re-arms it per delta thereafter;
790
- // an empty-partial stall classifies stream_stalled → retry/fallback.
791
- _armSemanticIdle();
854
+ // Initial wait is governed only by the fixed first-server-event policy.
855
+ // Semantic idle begins in meaningful() after a parsed server event, so
856
+ // a lower semantic-idle override cannot shorten this first-event wait.
857
+ _armFirstServerEvent();
792
858
  while (true) {
793
859
  if (totalTimeout.signal?.aborted) {
794
860
  _clearSemanticIdle();
@@ -834,6 +900,7 @@ export async function sendViaHttpSse({
834
900
  throw err;
835
901
  } finally {
836
902
  _pendingReadReject = null;
903
+ _clearFirstServerEvent();
837
904
  _clearSemanticIdle();
838
905
  try { reader.releaseLock?.(); } catch {}
839
906
  if (_onTotalAbort && totalTimeout.signal) {