mixdog 0.9.53 → 0.9.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/package.json +2 -1
  2. package/scripts/agent-model-liveness-test.mjs +68 -0
  3. package/scripts/anthropic-transport-policy-test.mjs +260 -0
  4. package/scripts/desktop-session-bridge-test.mjs +47 -0
  5. package/scripts/gemini-provider-test.mjs +396 -1
  6. package/scripts/grok-oauth-refresh-race-test.mjs +76 -1
  7. package/scripts/interrupted-turn-history-test.mjs +28 -0
  8. package/scripts/openai-oauth-ws-1006-retry-test.mjs +81 -1
  9. package/scripts/pending-completion-drop-test.mjs +160 -4
  10. package/scripts/pending-messages-lock-nonblocking-test.mjs +62 -0
  11. package/scripts/process-lifecycle-test.mjs +18 -4
  12. package/scripts/prompt-input-parity-test.mjs +145 -0
  13. package/scripts/provider-admission-scheduler-test.mjs +4 -5
  14. package/scripts/provider-contract-test.mjs +91 -33
  15. package/scripts/provider-toolcall-test.mjs +97 -17
  16. package/scripts/streaming-tail-window-test.mjs +146 -0
  17. package/scripts/tui-runtime-load-bench-entry.jsx +608 -0
  18. package/scripts/tui-runtime-load-bench.mjs +45 -0
  19. package/scripts/tui-store-frame-batch-test.mjs +99 -0
  20. package/scripts/tui-transcript-perf-test.mjs +367 -2
  21. package/scripts/write-backpressure-test.mjs +147 -0
  22. package/src/cli.mjs +5 -5
  23. package/src/lib/keychain-cjs.cjs +114 -25
  24. package/src/repl.mjs +11 -0
  25. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +62 -25
  26. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +8 -2
  27. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +50 -23
  28. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +15 -4
  29. package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +136 -27
  30. package/src/runtime/agent/orchestrator/providers/gemini.mjs +104 -20
  31. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +96 -75
  32. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +40 -1
  33. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +5 -0
  34. package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +35 -4
  35. package/src/runtime/agent/orchestrator/providers/openai-compat-xai.mjs +3 -1
  36. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +49 -9
  37. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +14 -2
  38. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +9 -4
  39. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +53 -13
  40. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +145 -23
  41. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +20 -4
  42. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +316 -63
  43. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +23 -1
  44. package/src/runtime/agent/orchestrator/session/store.mjs +46 -1
  45. package/src/runtime/agent/orchestrator/stall-policy.mjs +6 -13
  46. package/src/runtime/memory/lib/trace-store.mjs +66 -4
  47. package/src/runtime/shared/buffered-appender.mjs +83 -4
  48. package/src/runtime/shared/memory-snapshot.mjs +45 -36
  49. package/src/runtime/shared/process-lifecycle.mjs +166 -45
  50. package/src/runtime/shared/process-shutdown.mjs +2 -1
  51. package/src/session-runtime/model-route-api.mjs +4 -1
  52. package/src/session-runtime/native-search.mjs +4 -2
  53. package/src/session-runtime/provider-auth-api.mjs +8 -1
  54. package/src/session-runtime/provider-models.mjs +8 -3
  55. package/src/session-runtime/resource-api.mjs +2 -1
  56. package/src/session-runtime/runtime-core.mjs +32 -0
  57. package/src/session-runtime/session-turn-api.mjs +1 -0
  58. package/src/session-runtime/warmup-schedulers.mjs +5 -1
  59. package/src/standalone/agent-tool.mjs +19 -1
  60. package/src/tui/App.jsx +10 -4
  61. package/src/tui/app/text-layout.mjs +9 -1
  62. package/src/tui/app/transcript-window.mjs +146 -60
  63. package/src/tui/app/use-mouse-input.mjs +16 -8
  64. package/src/tui/app/use-transcript-scroll.mjs +71 -19
  65. package/src/tui/app/use-transcript-window.mjs +21 -10
  66. package/src/tui/components/PromptInput.jsx +13 -6
  67. package/src/tui/dist/index.mjs +1094 -232
  68. package/src/tui/engine/context-state.mjs +31 -31
  69. package/src/tui/engine/frame-batched-store.mjs +75 -0
  70. package/src/tui/engine/session-api-ext.mjs +6 -1
  71. package/src/tui/engine/session-api.mjs +9 -3
  72. package/src/tui/engine/session-flow.mjs +54 -27
  73. package/src/tui/engine/turn.mjs +44 -3
  74. package/src/tui/engine.mjs +515 -36
  75. package/src/tui/input-editing.mjs +33 -13
  76. package/src/tui/markdown/measure-rendered-rows.mjs +117 -5
  77. package/vendor/ink/build/ink.js +8 -16
@@ -5,20 +5,16 @@
5
5
  * https://auth.x.ai/.well-known/openid-configuration). Credentials come from
6
6
  * Mixdog's own token store (grok-oauth.json).
7
7
  *
8
- * Inference + catalog merge two sources, routed per model:
9
- * - api.x.ai/v1 (default): grok-4.x chat models and the web_search backend.
10
- * - cli-chat-proxy.grok.com/v1 (the grok-build proxy): proxy-only models
11
- * grok-build and grok-composer-2.5-fast, which api.x.ai does not publish.
12
- * The proxy version-gates /responses (HTTP 426); we clear it with the Grok
13
- * CLI client headers (proxyHeaders, real local version from version.json).
8
+ * Every OAuth inference request routes through cli-chat-proxy.grok.com/v1,
9
+ * matching Grok Build's session-auth contract. Model discovery still merges
10
+ * api.x.ai and proxy catalogs because each publishes a different subset.
14
11
  *
15
12
  * Inference is delegated to an inner OpenAICompatProvider('xai') — the only
16
- * preset wired for the Responses API — with the base URL + (for proxy models)
17
- * the CLI headers injected via config.extraHeaders, bearer swapped for the
18
- * OAuth access token. The model catalog is the union of both endpoints.
13
+ * preset wired for the Responses API — with the proxy URL + CLI headers
14
+ * injected via config.extraHeaders, bearer swapped for the OAuth access token.
19
15
  */
20
16
  import { createServer } from 'http';
21
- import { randomBytes, createHash } from 'crypto';
17
+ import { randomBytes, randomUUID, createHash } from 'crypto';
22
18
  import { readFileSync, existsSync, mkdirSync, statSync, unlinkSync } from 'fs';
23
19
  import { join, resolve } from 'path';
24
20
  import { getPluginData } from '../config.mjs';
@@ -81,33 +77,33 @@ function grokCliVersion() {
81
77
  // Headers the Grok CLI sends to clear the proxy version gate — extracted from
82
78
  // the grok binary: x-grok-client-version (the actual 426 gate),
83
79
  // x-grok-client-identifier, and a matching User-Agent.
84
- function proxyHeaders() {
80
+ function proxyHeaders({ model, sendOpts, userId } = {}) {
85
81
  const v = grokCliVersion();
86
- return {
82
+ const headers = {
87
83
  'x-grok-client-version': v,
88
84
  'x-grok-client-identifier': GROK_CLIENT_IDENTIFIER,
89
85
  'User-Agent': `xai-grok-build/${v}`,
90
86
  };
87
+ const sessionId = String(sendOpts?.sessionId || sendOpts?.session?.id || '').trim();
88
+ const requestId = String(sendOpts?.requestId || '').trim() || (sendOpts ? randomUUID() : '');
89
+ const turnIndex = sendOpts?.iteration;
90
+ // Mixdog has an authoritative session id but no distinct Grok conversation
91
+ // id. Do not synthesize the latter from the former.
92
+ if (sessionId) headers['x-grok-session-id'] = sessionId;
93
+ if (requestId) headers['x-grok-req-id'] = requestId;
94
+ if (model) headers['x-grok-model-override'] = String(model);
95
+ if (turnIndex != null && Number.isFinite(Number(turnIndex))) {
96
+ headers['x-grok-turn-idx'] = String(turnIndex);
97
+ }
98
+ if (userId) headers['x-grok-user-id'] = String(userId);
99
+ return headers;
91
100
  }
92
101
 
93
- function resolveGrokOAuthResponsesTransport(config) {
94
- // An explicit Grok-specific transport setting is the escape hatch and wins
95
- // for EVERY Grok model, api.x.ai and proxy-only alike — e.g. pin 'http' to
96
- // force proxy-only models back onto cli-chat-proxy.grok.com over HTTP.
97
- const raw = config?.responsesTransport
98
- ?? config?.transport
99
- ?? process.env.MIXDOG_GROK_OAUTH_RESPONSES_TRANSPORT
100
- ?? process.env.MIXDOG_GROK_OAUTH_TRANSPORT
101
- ?? '';
102
- const mode = String(raw).trim().toLowerCase();
103
- if (mode) return mode;
104
- // No Grok-specific override: leave the field unset so ALL api.x.ai-capable
105
- // Grok models honor the shared MIXDOG_OAI_TRANSPORT switch. Proxy-only
106
- // grok-build is included: the shared xAI WebSocket connector targets the
107
- // fixed wss://api.x.ai/v1/responses endpoint (not the proxy baseURL), and a
108
- // live probe confirmed grok-build serves there with the OAuth token. No
109
- // implicit HTTP pin — the proxy baseURL is only used when HTTP is selected.
110
- return undefined;
102
+ function resolveGrokOAuthResponsesTransport() {
103
+ // The OAuth bearer is session auth and must never escape to api.x.ai.
104
+ // Mixdog's xAI WebSocket connector has a fixed api.x.ai endpoint, so OAuth
105
+ // inference is pinned to the proxy's reference HTTP/SSE transport.
106
+ return 'http';
111
107
  }
112
108
 
113
109
  // Retired model aliases xAI no longer exposes by their old ids. The live
@@ -221,13 +217,18 @@ function _identityFromAccessToken(token) {
221
217
  const parts = String(token || '').split('.');
222
218
  if (parts.length !== 3) return {};
223
219
  const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
220
+ const principalId = payload?.principal_id || payload?.principalId || '';
221
+ const principalType = payload?.principal_type || payload?.principalType || '';
224
222
  const userId = payload?.user_id
225
223
  || payload?.userId
226
- || payload?.principal_id
227
- || payload?.principalId
224
+ || principalId
228
225
  || payload?.sub
229
226
  || '';
230
- return userId ? { user_id: String(userId) } : {};
227
+ return {
228
+ ...(userId ? { user_id: String(userId) } : {}),
229
+ ...(principalType ? { principal_type: String(principalType) } : {}),
230
+ ...(principalId ? { principal_id: String(principalId) } : {}),
231
+ };
231
232
  } catch { return {}; }
232
233
  }
233
234
 
@@ -243,12 +244,15 @@ function _loadOwnTokens() {
243
244
  try {
244
245
  const raw = JSON.parse(readFileSync(path, 'utf-8'));
245
246
  if (!raw?.access_token || !raw?.refresh_token) return null;
247
+ const identity = _identityFromAccessToken(raw.access_token);
246
248
  return {
247
249
  access_token: raw.access_token,
248
250
  refresh_token: raw.refresh_token,
249
251
  expires_at: _normalizeExpiresAt(raw.expires_at ?? raw.expiresAt) || _expiryFromAccessToken(raw.access_token),
250
252
  token_endpoint: raw.token_endpoint || null,
251
- user_id: raw.user_id || raw.userId || _identityFromAccessToken(raw.access_token).user_id || '',
253
+ user_id: raw.user_id || raw.userId || identity.user_id || '',
254
+ principal_type: raw.principal_type || raw.principalType || identity.principal_type || '',
255
+ principal_id: raw.principal_id || raw.principalId || identity.principal_id || '',
252
256
  source: 'own',
253
257
  mtimeMs: _mtimeMs(path),
254
258
  };
@@ -261,12 +265,15 @@ function loadTokens() {
261
265
  }
262
266
 
263
267
  function saveTokens(tokens) {
268
+ const identity = _identityFromAccessToken(tokens.access_token);
264
269
  writeJsonAtomicSync(getOwnTokenPath(), {
265
270
  access_token: tokens.access_token,
266
271
  refresh_token: tokens.refresh_token,
267
272
  expires_at: tokens.expires_at || 0,
268
273
  token_endpoint: tokens.token_endpoint || null,
269
- user_id: tokens.user_id || tokens.userId || _identityFromAccessToken(tokens.access_token).user_id || undefined,
274
+ user_id: tokens.user_id || tokens.userId || identity.user_id || undefined,
275
+ principal_type: tokens.principal_type || tokens.principalType || identity.principal_type || undefined,
276
+ principal_id: tokens.principal_id || tokens.principalId || identity.principal_id || undefined,
270
277
  }, { lock: true, fsyncDir: true, mode: 0o600, secret: true });
271
278
  }
272
279
 
@@ -331,14 +338,17 @@ async function _postRefresh(tokens) {
331
338
  : (await fetchDiscovery()).token_endpoint;
332
339
  const timeout = createTimeoutSignal(null, TOKEN_TIMEOUT_MS, 'grok-oauth refresh');
333
340
  try {
341
+ const body = new URLSearchParams({
342
+ grant_type: 'refresh_token',
343
+ client_id: CLIENT_ID,
344
+ refresh_token: tokens.refresh_token,
345
+ });
346
+ if (tokens.principal_type) body.set('principal_type', tokens.principal_type);
347
+ if (tokens.principal_id) body.set('principal_id', tokens.principal_id);
334
348
  const res = await fetch(tokenEndpoint, {
335
349
  method: 'POST',
336
350
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
337
- body: new URLSearchParams({
338
- grant_type: 'refresh_token',
339
- client_id: CLIENT_ID,
340
- refresh_token: tokens.refresh_token,
341
- }),
351
+ body,
342
352
  // Never follow a redirect on a secret-bearing request: a trusted
343
353
  // token endpoint that 307/308-redirects would replay the
344
354
  // refresh_token to the redirect target. Fail loud instead.
@@ -350,13 +360,12 @@ async function _postRefresh(tokens) {
350
360
  let json = null;
351
361
  try { json = text ? JSON.parse(text) : null; } catch { /* handled below */ }
352
362
  if (!res.ok) {
353
- // 400/401 (or an explicit invalid_grant/revoked/reused body) means
354
- // this refresh_token was revoked or already consumed.
355
- const isInvalidGrant = res.status === 400 || res.status === 401
356
- || /invalid_grant|revoked|reused/i.test(text);
363
+ const oauthError = String(json?.error || '').toLowerCase();
364
+ const isInvalidGrant = oauthError === 'invalid_grant';
365
+ const isTerminalRefresh = isInvalidGrant || oauthError === 'invalid_client';
357
366
  throw Object.assign(
358
367
  new Error(`[grok-oauth] token refresh ${res.status}: ${_scrubTokens(text).slice(0, 200)}`),
359
- { isInvalidGrant },
368
+ { isInvalidGrant, isTerminalRefresh, oauthError: oauthError || null },
360
369
  );
361
370
  }
362
371
  const accessToken = json?.access_token;
@@ -371,6 +380,8 @@ async function _postRefresh(tokens) {
371
380
  : _normalizeExpiresAt(json?.expires_at),
372
381
  token_endpoint: tokenEndpoint,
373
382
  user_id: tokens.user_id || tokens.userId || _identityFromAccessToken(accessToken).user_id || '',
383
+ principal_type: json?.principal_type || tokens.principal_type || tokens.principalType || '',
384
+ principal_id: json?.principal_id || tokens.principal_id || tokens.principalId || '',
374
385
  };
375
386
  saveTokens(refreshed);
376
387
  return { ...refreshed, source: 'own', mtimeMs: _mtimeMs(getOwnTokenPath()) };
@@ -379,6 +390,20 @@ async function _postRefresh(tokens) {
379
390
  }
380
391
  }
381
392
 
393
+ async function _postRefreshWithRetry(tokens) {
394
+ for (let attempt = 0; attempt < 3; attempt += 1) {
395
+ try {
396
+ return await _postRefresh(tokens);
397
+ } catch (err) {
398
+ if (err?.isTerminalRefresh || attempt === 2) throw err;
399
+ const baseMs = Math.min(2_000, 200 * (2 ** attempt));
400
+ const delayMs = Math.round(baseMs + Math.random() * baseMs);
401
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
402
+ }
403
+ }
404
+ throw new Error('[grok-oauth] unreachable refresh retry state');
405
+ }
406
+
382
407
  // Mixdog processes share one grok-oauth.json and xAI rotates refresh tokens
383
408
  // single-use. Hold a cross-process lease across the re-read, exchange, and
384
409
  // atomic save so only one process can spend a generation.
@@ -403,7 +428,7 @@ async function refreshTokens(tokens, { force = false } = {}) {
403
428
 
404
429
  const current = disk || tokens;
405
430
  try {
406
- return await _postRefresh(current);
431
+ return await _postRefreshWithRetry(current);
407
432
  } catch (err) {
408
433
  if (err?.isInvalidGrant) {
409
434
  // A writer that does not participate in this lease may still
@@ -416,7 +441,7 @@ async function refreshTokens(tokens, { force = false } = {}) {
416
441
  && (!rotated.expires_at || rotated.expires_at >= validAfter)) {
417
442
  return rotated;
418
443
  }
419
- return await _postRefresh(rotated);
444
+ return await _postRefreshWithRetry(rotated);
420
445
  }
421
446
  }
422
447
  throw err;
@@ -660,27 +685,16 @@ export class GrokOAuthProvider {
660
685
  }
661
686
 
662
687
  // Build (or rebuild on token change) the inner OpenAI-compatible provider
663
- // that owns request shaping. name 'xai' selects the Responses API path the
664
- // Grok CLI proxy speaks; baseURL + bearer are overridden for grok-build.
665
- _ensureInner(token, model) {
666
- // Proxy-only models (grok-composer*, grok-build) live on the grok-build
667
- // CLI proxy: different baseURL + the Grok CLI client headers. Everything
668
- // else stays on api.x.ai. The cache key includes the route so an
669
- // interleaved sequence of api/proxy calls each get the right inner.
670
- const proxy = isProxyOnlyModel(model);
671
- const key = `${proxy ? 'proxy' : 'api'}:${token}`;
688
+ // that owns request shaping. Every OAuth model uses the Grok CLI proxy.
689
+ _ensureInner(token, model, requestHeaders = proxyHeaders({ model })) {
690
+ const key = `proxy:${token}:${JSON.stringify(requestHeaders)}`;
672
691
  if (this._inner && this._innerKey === key) return this._inner;
673
692
  this._inner = new OpenAICompatProvider('xai', {
674
693
  ...this.config,
675
694
  apiKey: token,
676
- baseURL: proxy ? PROXY_BASE_URL : INFERENCE_BASE_URL,
677
- // All Grok models — proxy-only grok-build included — inherit the
678
- // shared xAI transport switch (WS→api.x.ai by default). An explicit
679
- // Grok-specific http setting is the escape hatch back to the proxy.
680
- responsesTransport: resolveGrokOAuthResponsesTransport(this.config, proxy),
681
- // Proxy-only models additionally need the Grok CLI client headers to
682
- // clear the proxy version gate (HTTP 426 otherwise).
683
- ...(proxy ? { extraHeaders: proxyHeaders() } : {}),
695
+ baseURL: PROXY_BASE_URL,
696
+ responsesTransport: resolveGrokOAuthResponsesTransport(),
697
+ extraHeaders: requestHeaders,
684
698
  });
685
699
  this._innerKey = key;
686
700
  return this._inner;
@@ -693,13 +707,18 @@ export class GrokOAuthProvider {
693
707
  const warm = typeof this.config?.preconnectFn === 'function'
694
708
  ? this.config.preconnectFn
695
709
  : preconnect;
696
- warm(INFERENCE_BASE_URL);
710
+ warm(PROXY_BASE_URL);
697
711
  }
698
712
  const useModel = normalizeGrokModelId(
699
713
  model || await ensureLatestGrokModel(this),
700
714
  );
701
715
  const tokens = await this.ensureAuth();
702
- const inner = this._ensureInner(tokens.access_token, useModel);
716
+ const requestHeaders = proxyHeaders({
717
+ model: useModel,
718
+ sendOpts,
719
+ userId: tokens.user_id,
720
+ });
721
+ const inner = this._ensureInner(tokens.access_token, useModel, requestHeaders);
703
722
  const grokTools = normalizeGrokToolSchemas(tools);
704
723
  try {
705
724
  // Call _doSend directly, bypassing OpenAICompatProvider.send()'s
@@ -710,23 +729,22 @@ export class GrokOAuthProvider {
710
729
  // catalog to this token — no single-model lock.
711
730
  return await inner._doSend(messages, useModel, grokTools, sendOpts);
712
731
  } catch (err) {
713
- // Refresh-and-retry only on 401 (stale/expired access token).
732
+ // Refresh-and-retry on a server-rejected OAuth session (401/403).
714
733
  // Resolve the status from the structured field (falling back to the
715
734
  // shared classifier that derives it from the error text) rather than
716
- // ad-hoc string matching. A 403 is an entitlement signal (the
717
- // account's tier lacks the model) — refreshing the same grant can't
718
- // fix it, so it must surface unretried.
735
+ // ad-hoc string matching.
719
736
  populateHttpStatusFromMessage(err);
720
- if (Number(err?.httpStatus || err?.status) === 401) {
721
- // A stream-level 401 after text/tool dispatch cannot be safely
737
+ const rejectedStatus = Number(err?.httpStatus || err?.status);
738
+ if (rejectedStatus === 401 || rejectedStatus === 403) {
739
+ // A stream-level rejection after text/tool dispatch cannot be safely
722
740
  // replayed: the client has already observed output or may have
723
741
  // executed a side-effecting tool.
724
742
  if (err.liveTextEmitted === true || err.emittedToolCall === true || err.unsafeToRetry === true) {
725
743
  throw err;
726
744
  }
727
- process.stderr.write('[grok-oauth] 401, force-refreshing token...\n');
745
+ process.stderr.write(`[grok-oauth] ${rejectedStatus}, force-refreshing token...\n`);
728
746
  const fresh = await this.ensureAuth({ forceRefresh: true });
729
- const retryInner = this._ensureInner(fresh.access_token, useModel);
747
+ const retryInner = this._ensureInner(fresh.access_token, useModel, requestHeaders);
730
748
  const retryOpts = err?.__warmup?.usage
731
749
  ? { ...(sendOpts || {}), _carriedWarmup: err.__warmup }
732
750
  : sendOpts;
@@ -882,6 +900,7 @@ async function exchangeAuthorizationCode({ discovery, pkce, code }) {
882
900
  if (!json.access_token || !json.refresh_token) {
883
901
  throw new Error('[grok-oauth] token exchange response missing access_token or refresh_token');
884
902
  }
903
+ const identity = _identityFromAccessToken(json.access_token);
885
904
  const tokens = {
886
905
  access_token: json.access_token,
887
906
  refresh_token: json.refresh_token,
@@ -889,7 +908,9 @@ async function exchangeAuthorizationCode({ discovery, pkce, code }) {
889
908
  ? Date.now() + json.expires_in * 1000
890
909
  : _normalizeExpiresAt(json.expires_at),
891
910
  token_endpoint: discovery.token_endpoint,
892
- user_id: _identityFromAccessToken(json.access_token).user_id || '',
911
+ user_id: identity.user_id || '',
912
+ principal_type: json.principal_type || identity.principal_type || '',
913
+ principal_id: json.principal_id || identity.principal_id || '',
893
914
  };
894
915
  saveTokens(tokens);
895
916
  return tokens;
@@ -33,7 +33,7 @@ export function resolveAnthropicCacheTtls(opts) {
33
33
  tools: pick('tools', null),
34
34
  system: pick('system', ANTHROPIC_CACHE_TTL_STABLE),
35
35
  tier3: pick('tier3', ANTHROPIC_CACHE_TTL_STABLE),
36
- messages: pick('messages', ANTHROPIC_CACHE_TTL_STABLE),
36
+ messages: pick('messages', ANTHROPIC_CACHE_TTL_VOLATILE),
37
37
  };
38
38
  const ttlRank = (ttl) => (ttl === ANTHROPIC_CACHE_TTL_STABLE ? 2 : 1);
39
39
  let minRank = Infinity;
@@ -55,6 +55,45 @@ export function clampAnthropicThinkingBudget(value, maxTokens) {
55
55
  return Math.max(1024, Math.min(desired, ceiling));
56
56
  }
57
57
 
58
+ export function normalizeAnthropicNonStreamingResponse(message, fallbackModel = '') {
59
+ const blocks = Array.isArray(message?.content) ? message.content : [];
60
+ const text = blocks
61
+ .filter((block) => block?.type === 'text')
62
+ .map((block) => String(block.text || ''))
63
+ .join('');
64
+ const toolCalls = blocks
65
+ .filter((block) => block?.type === 'tool_use')
66
+ .map((block) => ({
67
+ id: block.id,
68
+ name: block.name,
69
+ arguments: block.input && typeof block.input === 'object' ? block.input : {},
70
+ }));
71
+ const thinkingBlocks = blocks.filter((block) => (
72
+ block?.type === 'thinking' || block?.type === 'redacted_thinking'
73
+ ));
74
+ const usage = message?.usage || {};
75
+ const input = Number(usage.input_tokens) || 0;
76
+ const cacheRead = Number(usage.cache_read_input_tokens) || 0;
77
+ const cacheWrite = Number(usage.cache_creation_input_tokens) || 0;
78
+ return {
79
+ content: text,
80
+ model: message?.model || fallbackModel,
81
+ toolCalls: toolCalls.length ? toolCalls : undefined,
82
+ stopReason: message?.stop_reason || null,
83
+ hasThinkingContent: thinkingBlocks.length > 0,
84
+ contentBlockTypes: blocks.map((block) => block?.type).filter(Boolean),
85
+ thinkingBlocks: thinkingBlocks.length ? thinkingBlocks : undefined,
86
+ usage: {
87
+ inputTokens: input,
88
+ outputTokens: Number(usage.output_tokens) || 0,
89
+ cachedTokens: cacheRead,
90
+ cacheWriteTokens: cacheWrite,
91
+ promptTokens: input + cacheRead + cacheWrite,
92
+ raw: usage,
93
+ },
94
+ };
95
+ }
96
+
58
97
  export function sanitizeAnthropicInputSchema(schema, toolName, logTag) {
59
98
  if (!schema || typeof schema !== 'object') {
60
99
  return { type: 'object', properties: {} };
@@ -765,6 +765,10 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
765
765
  const msg = event.response?.error?.message || event.error?.message || event.message || 'response.failed';
766
766
  const err = new Error(`xAI Responses stream response.failed: ${msg}`);
767
767
  populateHttpStatusFromMessage(err, msg);
768
+ // xAI's reference sampler treats protocol-level failed/error
769
+ // events as synthetic HTTP 500s. Gate by the xAI stream label so
770
+ // shared compat consumers retain their existing classification.
771
+ if (String(label || '').toLowerCase().startsWith('xai')) err.httpStatus = 500;
768
772
  throw err;
769
773
  }
770
774
  case 'response.incomplete': {
@@ -788,6 +792,7 @@ function handleCompatResponsesStreamEvent(event, state, { label, parseResponsesT
788
792
  const msg = event.message || event.error?.message || 'unknown';
789
793
  const err = new Error(`xAI Responses stream error: ${msg}`);
790
794
  populateHttpStatusFromMessage(err, msg);
795
+ if (String(label || '').toLowerCase().startsWith('xai')) err.httpStatus = 500;
791
796
  throw err;
792
797
  }
793
798
  default:
@@ -329,32 +329,61 @@ export function toXaiResponsesInput(messages, providerState, options = {}) {
329
329
  let startIndex = 0;
330
330
  let resetReason = null;
331
331
  let previousResponseId = typeof state?.previousResponseId === 'string' ? state.previousResponseId : null;
332
+ let statelessContinuation = state?.store === false;
332
333
  const expectedModel = options.model ? String(options.model) : '';
333
334
  const stateModel = state?.model ? String(state.model) : '';
334
335
  const seen = Number.isInteger(state?.seenMessageCount) ? state.seenMessageCount : null;
335
- if (previousResponseId && expectedModel && stateModel && stateModel !== expectedModel) {
336
+ if ((previousResponseId || statelessContinuation) && expectedModel && stateModel && stateModel !== expectedModel) {
336
337
  previousResponseId = null;
338
+ statelessContinuation = false;
337
339
  resetReason = 'model_changed';
338
340
  }
339
- if (previousResponseId && (seen == null || seen < 0 || seen > messages.length)) {
341
+ if ((previousResponseId || statelessContinuation) && (seen == null || seen < 0 || seen > messages.length)) {
340
342
  previousResponseId = null;
343
+ statelessContinuation = false;
341
344
  resetReason = seen == null ? 'missing_seen_message_count' : 'seen_message_count_out_of_range';
342
345
  }
343
346
  if (previousResponseId) {
344
347
  startIndex = Math.max(0, Math.min(seen, messages.length));
345
348
  if (messages[startIndex]?.role === 'assistant') startIndex += 1;
349
+ } else if (statelessContinuation) {
350
+ // store=false responses cannot be looked up by id. The reference
351
+ // ConversationRequest sends the complete retained conversation on
352
+ // every turn, so replay from the beginning rather than treating the
353
+ // encrypted reasoning item like stored server-side continuation.
354
+ startIndex = 0;
346
355
  }
347
- const dropToolHistory = !previousResponseId && (resetReason !== null || !state?.previousResponseId);
356
+ const dropToolHistory = !previousResponseId
357
+ && !statelessContinuation
358
+ && (resetReason !== null || !state?.previousResponseId);
348
359
  const input = [];
360
+ const reasoningByMessageIndex = new Map();
361
+ if (statelessContinuation) {
362
+ const history = Array.isArray(state?.encryptedReasoningHistory)
363
+ ? state.encryptedReasoningHistory
364
+ : (Array.isArray(state?.encryptedReasoningItems)
365
+ ? [{ messageIndex: seen, items: state.encryptedReasoningItems }]
366
+ : []);
367
+ for (const entry of history) {
368
+ const index = Number(entry?.messageIndex);
369
+ if (!Number.isInteger(index) || index < 0 || !Array.isArray(entry?.items)) continue;
370
+ const bucket = reasoningByMessageIndex.get(index) || [];
371
+ bucket.push(...entry.items.map((item) => ({ ...item })));
372
+ reasoningByMessageIndex.set(index, bucket);
373
+ }
374
+ }
349
375
  const pendingToolMedia = [];
350
376
  const customToolCallNameById = new Map();
351
377
  const flushToolMedia = () => {
352
378
  if (!pendingToolMedia.length) return;
353
379
  input.push({ role: 'user', content: pendingToolMedia.splice(0) });
354
380
  };
355
- for (const m of messages.slice(startIndex)) {
381
+ for (let messageIndex = startIndex; messageIndex < messages.length; messageIndex += 1) {
382
+ const m = messages[messageIndex];
356
383
  if (!includeSystem && m.role === 'system') continue;
357
384
  if (m.role !== 'tool') flushToolMedia();
385
+ const reasoningItems = reasoningByMessageIndex.get(messageIndex);
386
+ if (reasoningItems) input.push(...reasoningItems);
358
387
  if (dropToolHistory && m.role === 'tool') continue;
359
388
  if (dropToolHistory && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
360
389
  if (m.content) input.push({ role: 'assistant', content: normalizeContentForOpenAIResponses(m.content, { role: 'assistant' }) });
@@ -365,5 +394,7 @@ export function toXaiResponsesInput(messages, providerState, options = {}) {
365
394
  else input.push(converted);
366
395
  }
367
396
  flushToolMedia();
397
+ const trailingReasoning = reasoningByMessageIndex.get(messages.length);
398
+ if (trailingReasoning) input.push(...trailingReasoning);
368
399
  return { input, previousResponseId, startIndex, continuationResetReason: resetReason };
369
400
  }
@@ -147,7 +147,9 @@ export function useXaiResponsesWebSocket(opts, config) {
147
147
  ?? config?.transport
148
148
  ?? process.env.MIXDOG_XAI_RESPONSES_TRANSPORT
149
149
  ?? process.env.MIXDOG_XAI_TRANSPORT;
150
- if (raw == null || raw === '') return true;
150
+ // Reference xAI/Grok parity is HTTP/SSE. WebSocket remains available only
151
+ // when a caller explicitly selects a WS transport.
152
+ if (raw == null || raw === '') return false;
151
153
  const transport = String(raw).trim().toLowerCase();
152
154
  return !['0', 'false', 'off', 'http', 'https', 'responses-http', 'sdk'].includes(transport);
153
155
  }
@@ -98,6 +98,13 @@ function includeCompletedXaiWarmup(result, warmup) {
98
98
  } : usage,
99
99
  };
100
100
  }
101
+
102
+ function encryptedXaiReasoningItems(output) {
103
+ if (!Array.isArray(output)) return [];
104
+ return output
105
+ .filter((item) => item?.type === 'reasoning' && typeof item?.encrypted_content === 'string' && item.encrypted_content)
106
+ .map((item) => ({ ...item }));
107
+ }
101
108
  export { OPENAI_COMPAT_PRESETS } from './openai-compat-presets.mjs';
102
109
  const PRESETS = OPENAI_COMPAT_PRESETS;
103
110
  const MODEL_LIST_TIMEOUT_MS = resolveTimeoutMs(
@@ -575,7 +582,12 @@ export class OpenAICompatProvider {
575
582
  const params = {
576
583
  model: useModel,
577
584
  input,
578
- store: true,
585
+ // xAI's reference sampler is ZDR-safe by default and asks the
586
+ // service to return opaque reasoning state for continuation.
587
+ // This method is xAI-only; other compat providers retain their
588
+ // existing request bodies.
589
+ store: false,
590
+ include: ['reasoning.encrypted_content'],
579
591
  prompt_cache_key: cacheRouting.key,
580
592
  };
581
593
  if (previousResponseId) params.previous_response_id = previousResponseId;
@@ -709,6 +721,15 @@ export class OpenAICompatProvider {
709
721
  // provider requirement found — chain_continuous trace will surface
710
722
  // any rejection as a provider-side drop.)
711
723
  const nextPreviousResponseId = response.id;
724
+ const encryptedReasoningItems = encryptedXaiReasoningItems(response.output);
725
+ const encryptedReasoningHistory = [
726
+ ...(Array.isArray(opts.providerState?.xaiResponses?.encryptedReasoningHistory)
727
+ ? opts.providerState.xaiResponses.encryptedReasoningHistory
728
+ : []),
729
+ ...(encryptedReasoningItems.length
730
+ ? [{ messageIndex: Array.isArray(messages) ? messages.length : 0, items: encryptedReasoningItems }]
731
+ : []),
732
+ ];
712
733
  const searchSources = collectCompatResponseSearchSources(response);
713
734
  return {
714
735
  content: streamed.content,
@@ -724,9 +745,17 @@ export class OpenAICompatProvider {
724
745
  providerState: {
725
746
  ...(opts.providerState || {}),
726
747
  xaiResponses: {
727
- previousResponseId: nextPreviousResponseId,
748
+ previousResponseId: null,
749
+ responseId: nextPreviousResponseId,
750
+ store: false,
751
+ encryptedReasoningItems,
752
+ encryptedReasoningHistory,
728
753
  seenMessageCount: Array.isArray(messages) ? messages.length : 0,
729
- model: response.model || useModel,
754
+ // The proxy may return a deployment alias (for example
755
+ // grok-4.5-build) for a requested public model id. Chain
756
+ // compatibility is keyed to the requested id; storing the
757
+ // alias here would falsely reset the next tool-result turn.
758
+ model: useModel,
730
759
  updatedAt: Date.now(),
731
760
  },
732
761
  },
@@ -765,10 +794,8 @@ export class OpenAICompatProvider {
765
794
  const params = {
766
795
  model: useModel,
767
796
  input,
768
- // xAI's WebSocket continuation is documented for store=false, but
769
- // the public endpoint currently returns previous_response_not_found
770
- // in our live probes unless the chain is stored.
771
- store: true,
797
+ store: false,
798
+ include: ['reasoning.encrypted_content'],
772
799
  prompt_cache_key: cacheRouting.key,
773
800
  };
774
801
  const instructions = xaiSystemInstructions(messages);
@@ -846,6 +873,15 @@ export class OpenAICompatProvider {
846
873
  const responseId = result.responseId || previousResponseId || null;
847
874
  // Same rationale as the HTTP path above: `length` stop keeps the chain.
848
875
  const nextPreviousResponseId = responseId;
876
+ const encryptedReasoningItems = encryptedXaiReasoningItems(result.responseItems);
877
+ const encryptedReasoningHistory = [
878
+ ...(Array.isArray(opts.providerState?.xaiResponses?.encryptedReasoningHistory)
879
+ ? opts.providerState.xaiResponses.encryptedReasoningHistory
880
+ : []),
881
+ ...(encryptedReasoningItems.length
882
+ ? [{ messageIndex: Array.isArray(messages) ? messages.length : 0, items: encryptedReasoningItems }]
883
+ : []),
884
+ ];
849
885
  const rawUsage = result.usage?.raw || result.usage || null;
850
886
  const traceParams = result.__warmup?.requestBody || params;
851
887
  writeXaiResponsesCacheTrace({
@@ -899,9 +935,13 @@ export class OpenAICompatProvider {
899
935
  providerState: {
900
936
  ...(opts.providerState || {}),
901
937
  xaiResponses: {
902
- previousResponseId: nextPreviousResponseId,
938
+ previousResponseId: null,
939
+ responseId: nextPreviousResponseId,
940
+ store: false,
941
+ encryptedReasoningItems,
942
+ encryptedReasoningHistory,
903
943
  seenMessageCount: Array.isArray(messages) ? messages.length : 0,
904
- model: result.model || useModel,
944
+ model: useModel,
905
945
  updatedAt: Date.now(),
906
946
  transport: 'websocket',
907
947
  },
@@ -162,8 +162,10 @@ function _buildOpenAIHttpFallbackHeaders({ auth, cacheKey }) {
162
162
 
163
163
  // WS→HTTP/SSE fallback predicate → shared shouldFallbackTransport
164
164
  // (retry-classifier.mjs). The per-provider env flag is computed here and passed
165
- // as `enabled`; the deny-order + allow-list are identical to the former copy.
165
+ // as `enabled`; OAuth rate limits are terminal and must never trigger a
166
+ // transport fallback, even if a caller has marked a prior WS attempt exhausted.
166
167
  export function _shouldUseOpenAIHttpFallback(err, externalSignal) {
168
+ if (Number(err?.httpStatus || 0) === 429) return false;
167
169
  return shouldFallbackTransport(err, {
168
170
  signal: externalSignal,
169
171
  enabled: _envFlag('MIXDOG_OPENAI_OAUTH_HTTP_FALLBACK', true),
@@ -220,7 +222,17 @@ export async function sendViaHttpSse({
220
222
  );
221
223
  let requestError = null;
222
224
  try {
223
- try { onStageChange?.('requesting'); } catch {}
225
+ // Keep the established stage name for consumers, while making each
226
+ // bounded retry an observable liveness heartbeat. Session runtime
227
+ // liveness updates lastProgressAt for every stage callback,
228
+ // including a repeated `requesting` stage.
229
+ try {
230
+ onStageChange?.('requesting', {
231
+ attempt: attempt + 1,
232
+ maxAttempts: CODEX_REQUEST_MAX_RETRIES + 1,
233
+ retry: attempt > 0,
234
+ });
235
+ } catch {}
224
236
  response = await fetchFn(responsesUrl, {
225
237
  method: 'POST',
226
238
  headers,