@yeaft/webchat-agent 1.0.302 → 1.0.304

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.
@@ -1 +1 @@
1
- {"version":"1.0.302"}
1
+ {"version":"1.0.304"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.302",
3
+ "version": "1.0.304",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/config.js CHANGED
@@ -77,6 +77,7 @@ const DEFAULTS = {
77
77
  maxDelayMs: 30_000,
78
78
  jitterRatio: 0.25,
79
79
  streamIdleTimeoutMs: 90_000,
80
+ forbiddenRetryDelaysMs: [30_000, 120_000],
80
81
  },
81
82
  };
82
83
 
@@ -114,6 +115,12 @@ export function normalizeLlmRetry(fileConfig, overrides) {
114
115
  if (Number.isFinite(src.streamIdleTimeoutMs) && src.streamIdleTimeoutMs >= 0) {
115
116
  out.streamIdleTimeoutMs = Math.min(600_000, Math.floor(src.streamIdleTimeoutMs));
116
117
  }
118
+ if (Array.isArray(src.forbiddenRetryDelaysMs)) {
119
+ out.forbiddenRetryDelaysMs = src.forbiddenRetryDelaysMs
120
+ .filter(v => Number.isFinite(v) && v >= 0)
121
+ .slice(0, 3)
122
+ .map(v => Math.min(600_000, Math.floor(v)));
123
+ }
117
124
  };
118
125
  apply(fileConfig);
119
126
  apply(overrides);
package/yeaft/engine.js CHANGED
@@ -22,7 +22,7 @@ import { promises as fsp } from 'fs';
22
22
  import { join, resolve as resolvePath } from 'path';
23
23
  import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
24
24
  import { getRuntimePlatformInfo } from './runtime-platform.js';
25
- import { LLMContextError, LLMAbortError, LLMRateLimitError, LLMServerError, LLMStreamIdleTimeoutError } from './llm/adapter.js';
25
+ import { LLMContextError, LLMAbortError, LLMAuthError, LLMRateLimitError, LLMServerError, LLMStreamIdleTimeoutError } from './llm/adapter.js';
26
26
  import { runMemoryPreflow, buildRelevantScopes, memoryScopeLabel } from './sessions/pre-flow.js';
27
27
  import { readProjectDoc, pickProjectDocFile, DEFAULT_PROJECT_DOC_MAX_BYTES } from './sessions/project-doc.js';
28
28
  import { partitionMessages } from './compact/partition.js';
@@ -103,6 +103,7 @@ const RETRY_DEFAULTS = Object.freeze({
103
103
  baseDelayMs: 1_000,
104
104
  maxDelayMs: 30_000,
105
105
  jitterRatio: 0.25,
106
+ forbiddenRetryDelaysMs: [30_000, 120_000],
106
107
  });
107
108
 
108
109
  const RETRY_CONTINUATION_PROMPT =
@@ -159,11 +160,18 @@ function stripLeadingSkillCommandFromPromptParts(promptParts, skillManager) {
159
160
  function resolveRetryPolicy(config) {
160
161
  const raw = config?.llmRetry || {};
161
162
  const num = (v, d) => (Number.isFinite(v) && v >= 0 ? v : d);
163
+ const forbiddenRetryDelaysMs = Array.isArray(raw.forbiddenRetryDelaysMs)
164
+ ? raw.forbiddenRetryDelaysMs
165
+ .filter(v => Number.isFinite(v) && v >= 0)
166
+ .slice(0, 3)
167
+ .map(v => Math.min(600_000, Math.floor(v)))
168
+ : RETRY_DEFAULTS.forbiddenRetryDelaysMs;
162
169
  return {
163
170
  maxRetries: Math.max(0, Math.floor(num(raw.maxRetries, RETRY_DEFAULTS.maxRetries))),
164
171
  baseDelayMs: Math.max(0, Math.floor(num(raw.baseDelayMs, RETRY_DEFAULTS.baseDelayMs))),
165
172
  maxDelayMs: Math.max(0, Math.floor(num(raw.maxDelayMs, RETRY_DEFAULTS.maxDelayMs))),
166
173
  jitterRatio: Math.min(1, Math.max(0, num(raw.jitterRatio, RETRY_DEFAULTS.jitterRatio))),
174
+ forbiddenRetryDelaysMs,
167
175
  };
168
176
  }
169
177
 
@@ -2408,6 +2416,7 @@ export class Engine {
2408
2416
  // and does NOT count against this budget.
2409
2417
  const retryPolicy = resolveRetryPolicy(this.#config);
2410
2418
  let consecutiveRetryableErrors = 0;
2419
+ let consecutiveForbiddenErrors = 0;
2411
2420
 
2412
2421
  while (true) {
2413
2422
  turnNumber++;
@@ -2949,6 +2958,34 @@ export class Engine {
2949
2958
  // leave ambiguous execution ownership, so only pre-tool failures are
2950
2959
  // eligible for transparent retry or model fallback.
2951
2960
  const canReplayProviderRequest = toolCalls.length === 0;
2961
+ const earlyIsTemporaryForbidden = err instanceof LLMAuthError
2962
+ && err.statusCode === 403
2963
+ && err.temporary === true;
2964
+ if (earlyIsTemporaryForbidden && canReplayProviderRequest
2965
+ && consecutiveForbiddenErrors < retryPolicy.forbiddenRetryDelaysMs.length) {
2966
+ const delayMs = retryPolicy.forbiddenRetryDelaysMs[consecutiveForbiddenErrors];
2967
+ consecutiveForbiddenErrors += 1;
2968
+ endAttemptTrace('llm_retry');
2969
+ yield {
2970
+ type: 'llm_retry',
2971
+ attempt: consecutiveForbiddenErrors,
2972
+ maxRetries: retryPolicy.forbiddenRetryDelaysMs.length,
2973
+ delayMs,
2974
+ reason: 'temporary_forbidden',
2975
+ recoveryMode: 'restart',
2976
+ errorName: err.name,
2977
+ statusCode: 403,
2978
+ message: `LLM provider returned HTTP 403; retry ${consecutiveForbiddenErrors}/${retryPolicy.forbiddenRetryDelaysMs.length}`,
2979
+ };
2980
+ const slept = await sleepWithAbort(delayMs, signal);
2981
+ if (!slept || signal?.aborted) {
2982
+ yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2983
+ yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
2984
+ break;
2985
+ }
2986
+ yield { type: 'turn_end', turnNumber, stopReason: 'llm_retry', threadId };
2987
+ continue;
2988
+ }
2952
2989
  if ((earlyIsRateLimit || earlyIsTransient) && canReplayProviderRequest) {
2953
2990
  if (consecutiveRetryableErrors < retryPolicy.maxRetries) {
2954
2991
  consecutiveRetryableErrors += 1;
@@ -74,15 +74,70 @@ export class LLMRateLimitError extends Error {
74
74
  }
75
75
  }
76
76
 
77
- /** Authentication error (401, 403) — need to re-authenticate. */
77
+ /** Authentication / authorization error (401, 403). */
78
78
  export class LLMAuthError extends Error {
79
- constructor(message, statusCode) {
79
+ constructor(message, statusCode, details = {}) {
80
80
  super(message);
81
81
  this.name = 'LLMAuthError';
82
82
  this.statusCode = statusCode;
83
+ this.reasonCode = details.reasonCode || (statusCode === 401 ? 'invalid_credentials' : 'permission_denied');
84
+ this.temporary = details.temporary === true;
85
+ this.provider = details.provider || null;
86
+ this.model = details.model || null;
87
+ this.credentialRefreshable = details.credentialRefreshable === true;
88
+ }
89
+ }
90
+
91
+ const PERMANENT_FORBIDDEN_RE = /(?:access[_ -]?denied|not[_ -]?authorized|unauthori[sz]ed|authorization[_ -]?denied|policy|safety|content[_ -]?filter|entitle(?:ment|d)|subscription[_ -]?(?:required|inactive|expired)|plan[_ -]?(?:required|does[_ -]?not[_ -]?include)|model[^\n]{0,60}(?:access|permission|unavailable|not[_ -]?available|plan)|permission[^\n]{0,40}model|account[^\n]{0,40}(?:disabled|suspended)|organization[^\n]{0,40}(?:disabled|suspended)|insufficient[_ -]?(?:permission|scope))/i;
92
+ const PERMANENT_FORBIDDEN_CODE_RE = /(?:access_denied|permission_denied|unauthorized|not_authorized|insufficient_(?:scope|permission)|model_(?:access_denied|not_available)|subscription_required|not_entitled|policy_violation|content_filter)/i;
93
+
94
+ function parseProviderErrorBody(responseBody) {
95
+ if (typeof responseBody !== 'string') return responseBody && typeof responseBody === 'object' ? responseBody : null;
96
+ try {
97
+ const parsed = JSON.parse(responseBody);
98
+ return parsed && typeof parsed === 'object' ? parsed : null;
99
+ } catch {
100
+ return null;
83
101
  }
84
102
  }
85
103
 
104
+ function providerErrorSignals(responseBody) {
105
+ const parsed = parseProviderErrorBody(responseBody);
106
+ const error = parsed?.error && typeof parsed.error === 'object' ? parsed.error : parsed;
107
+ const code = [error?.code, error?.type, error?.reason, parsed?.code, parsed?.type]
108
+ .filter(value => typeof value === 'string')
109
+ .join(' ');
110
+ const message = [error?.message, parsed?.message, typeof responseBody === 'string' ? responseBody : '']
111
+ .filter(value => typeof value === 'string')
112
+ .join(' ');
113
+ return { code, message };
114
+ }
115
+
116
+ /**
117
+ * Build a user-safe auth error. The provider response body is used only for
118
+ * classification and is deliberately excluded from the message; raw response
119
+ * capture remains available in the bounded debug trace.
120
+ */
121
+ export function classifyAuthError(statusCode, responseBody = '', details = {}) {
122
+ const status = Number(statusCode) || 0;
123
+ if (status === 401) {
124
+ return new LLMAuthError('LLM provider returned HTTP 401 (invalid credentials)', status, {
125
+ ...details,
126
+ reasonCode: 'invalid_credentials',
127
+ temporary: false,
128
+ });
129
+ }
130
+ const signals = providerErrorSignals(responseBody);
131
+ const permanent = PERMANENT_FORBIDDEN_CODE_RE.test(signals.code)
132
+ || PERMANENT_FORBIDDEN_RE.test(signals.message);
133
+ const reasonCode = permanent ? 'permission_denied' : 'unknown_forbidden';
134
+ return new LLMAuthError(`LLM provider returned HTTP 403 (${reasonCode})`, status, {
135
+ ...details,
136
+ reasonCode,
137
+ temporary: !permanent,
138
+ });
139
+ }
140
+
86
141
  /** Context too long error (413 or API-specific) — need compaction. */
87
142
  export class LLMContextError extends Error {
88
143
  constructor(message) {
@@ -12,6 +12,7 @@ import {
12
12
  classifyFetchError,
13
13
  retryAfterFromResponse,
14
14
  LLMAuthError,
15
+ classifyAuthError,
15
16
  LLMContextError,
16
17
  LLMServerError,
17
18
  LLMAbortError,
@@ -218,7 +219,7 @@ export class AnthropicAdapter extends LLMAdapter {
218
219
  #classifyError(status, body, response = null) {
219
220
  const authHint = `auth=${this.#authHeaderMode}`;
220
221
  if (status === 401 || status === 403) {
221
- return new LLMAuthError(`Anthropic auth error (${authHint}): ${body}`, status);
222
+ return classifyAuthError(status, body);
222
223
  }
223
224
  if (status === 429) {
224
225
  const retryAfter = retryAfterFromResponse(response);
@@ -59,6 +59,16 @@ const JWT_REFRESH_MARGIN_SECONDS = 120;
59
59
  const DEVICE_POLL_SAFETY_MARGIN_SECONDS = 3;
60
60
  const DEFAULT_DEVICE_TIMEOUT_SECONDS = 300;
61
61
 
62
+ /** Safe credential exchange failure. Provider response bodies are never attached. */
63
+ export class CopilotCredentialError extends Error {
64
+ constructor(message, statusCode = null, reasonCode = 'credential_exchange_failed') {
65
+ super(message);
66
+ this.name = 'CopilotCredentialError';
67
+ this.statusCode = Number.isFinite(statusCode) ? statusCode : null;
68
+ this.reasonCode = reasonCode;
69
+ }
70
+ }
71
+
62
72
  const CREDENTIALS_DIR = join(homedir(), '.yeaft', 'credentials');
63
73
  const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'github-copilot.json');
64
74
 
@@ -80,6 +90,11 @@ export function _resetCacheForTests() {
80
90
  _exchangeInFlight.clear();
81
91
  }
82
92
 
93
+ /** Drop exchanged API tokens after the provider rejects one with HTTP 401. */
94
+ export function invalidateApiTokenCache() {
95
+ _jwtCache.clear();
96
+ }
97
+
83
98
  /**
84
99
  * Anchor for `gh auth token` output validation. gh prints either a bare
85
100
  * token followed by a newline or, on misconfiguration, a help banner /
@@ -270,12 +285,20 @@ export async function exchangeToken(rawToken, { fetchFn = fetch } = {}) {
270
285
  });
271
286
 
272
287
  if (!res.ok) {
273
- const body = await res.text().catch(() => '');
274
- throw new Error(`Copilot token exchange failed: HTTP ${res.status} ${body.slice(0, 200)}`);
288
+ throw new CopilotCredentialError(
289
+ `Copilot token exchange failed with HTTP ${res.status}`,
290
+ res.status,
291
+ );
275
292
  }
276
293
  const data = await res.json();
277
294
  const apiToken = data && typeof data.token === 'string' ? data.token : '';
278
- if (!apiToken) throw new Error('Copilot token exchange returned empty token');
295
+ if (!apiToken) {
296
+ throw new CopilotCredentialError(
297
+ 'Copilot token exchange returned no API token',
298
+ res.status,
299
+ 'credential_exchange_invalid_response',
300
+ );
301
+ }
279
302
 
280
303
  const expiresAtRaw = data.expires_at;
281
304
  const expiresAt =
@@ -307,13 +330,14 @@ export async function exchangeToken(rawToken, { fetchFn = fetch } = {}) {
307
330
  * @param {typeof fetch} [opts.fetchFn]
308
331
  * @returns {Promise<{token: string, source: string, exchanged: boolean} | null>}
309
332
  */
310
- export async function getApiToken({ hostname, fetchFn = fetch } = {}) {
333
+ export async function getApiToken({ hostname, fetchFn = fetch, requireExchange = false } = {}) {
311
334
  const raw = await resolveRawToken({ hostname });
312
335
  if (!raw) return null;
313
336
  try {
314
337
  const { apiToken } = await exchangeToken(raw.token, { fetchFn });
315
338
  return { token: apiToken, source: raw.source, exchanged: true };
316
- } catch {
339
+ } catch (err) {
340
+ if (requireExchange) throw err;
317
341
  return { token: raw.token, source: raw.source, exchanged: false };
318
342
  }
319
343
  }
@@ -20,7 +20,7 @@
20
20
  import * as githubCopilot from './github-copilot.js';
21
21
 
22
22
  /**
23
- * @typedef {{ getApiKey: () => Promise<string>, name: string }} CredentialProvider
23
+ * @typedef {{ getApiKey: () => Promise<string>, refreshApiKey?: () => Promise<string>, name: string }} CredentialProvider
24
24
  */
25
25
 
26
26
  /**
@@ -42,6 +42,18 @@ export function getCredentialProvider(name) {
42
42
  }
43
43
  return r.token;
44
44
  },
45
+ async refreshApiKey() {
46
+ githubCopilot.invalidateApiTokenCache();
47
+ const r = await githubCopilot.getApiToken({ requireExchange: true });
48
+ if (!r?.token) {
49
+ throw new githubCopilot.CopilotCredentialError(
50
+ 'GitHub Copilot credential refresh found no usable credential',
51
+ null,
52
+ 'credential_unavailable',
53
+ );
54
+ }
55
+ return r.token;
56
+ },
45
57
  };
46
58
  }
47
59
  return null;
@@ -29,6 +29,7 @@ import {
29
29
  LLMAdapter,
30
30
  LLMRateLimitError,
31
31
  LLMAuthError,
32
+ classifyAuthError,
32
33
  LLMContextError,
33
34
  LLMServerError,
34
35
  LLMAbortError,
@@ -207,7 +208,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
207
208
 
208
209
  #classifyError(status, body, response = null) {
209
210
  if (status === 401 || status === 403) {
210
- return new LLMAuthError(`Auth error: ${body}`, status);
211
+ return classifyAuthError(status, body);
211
212
  }
212
213
  if (status === 429) {
213
214
  const retryAfter = retryAfterFromResponse(response);
@@ -21,7 +21,7 @@
21
21
  * Phase 7 removed the legacy "openai" (Chat Completions) protocol entirely.
22
22
  */
23
23
 
24
- import { LLMAdapter } from './adapter.js';
24
+ import { LLMAdapter, LLMAuthError } from './adapter.js';
25
25
  import { getModelEffortOptions, getThinkingCapability, normalizeEffort, normalizeEffortOptions, parseModelRef } from '../models.js';
26
26
  import {
27
27
  GITHUB_COPILOT_BASE_URL,
@@ -563,18 +563,62 @@ export class AdapterRouter extends LLMAdapter {
563
563
  * @param {import('./adapter.js').StreamParams} params
564
564
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
565
565
  */
566
+ async #refreshRejectedCredential(provider, model) {
567
+ if (!provider?.credentialProvider) return false;
568
+ try {
569
+ if (provider.credentialProvider === 'github-copilot' && provider.githubToken) {
570
+ const githubCopilot = await import('./credentials/github-copilot.js');
571
+ githubCopilot.invalidateApiTokenCache();
572
+ await githubCopilot.exchangeToken(provider.githubToken);
573
+ } else {
574
+ const { getCredentialProvider } = await import('./credentials/index.js');
575
+ const credentialProvider = getCredentialProvider(provider.credentialProvider);
576
+ if (!credentialProvider?.refreshApiKey) return false;
577
+ await credentialProvider.refreshApiKey();
578
+ }
579
+ } catch (err) {
580
+ throw new LLMAuthError('LLM credential refresh failed', err?.statusCode ?? 401, {
581
+ reasonCode: err?.reasonCode || 'credential_refresh_failed',
582
+ provider: provider.name || null,
583
+ model,
584
+ credentialRefreshable: true,
585
+ });
586
+ }
587
+ this.#adapterCache.clear();
588
+ return true;
589
+ }
590
+
591
+ #annotateAuthError(err, provider, model) {
592
+ if (err?.name !== 'LLMAuthError') return;
593
+ err.provider = provider?.name || null;
594
+ err.model = model;
595
+ err.credentialRefreshable = Boolean(provider?.credentialProvider);
596
+ }
597
+
566
598
  async *stream(params) {
567
- const resolved = await this.#resolveAdapter(params.model);
568
- const effortContext = {
569
- protocol: resolved.protocol,
570
- supportsEffort: resolved.entry?.supportsEffort,
571
- effortOptions: resolved.entry?.effortOptions,
572
- thinkingProtocol: resolved.entry?.thinkingProtocol,
573
- maxBudgetTokens: resolved.entry?.maxBudgetTokens,
574
- };
575
- const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
576
- const sanitized = sanitizeMessagesForWire(filtered);
577
- yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext });
599
+ let refreshedCredential = false;
600
+ while (true) {
601
+ const resolved = await this.#resolveAdapter(params.model);
602
+ const provider = this.getProviderForModel(params.model);
603
+ const effortContext = {
604
+ protocol: resolved.protocol,
605
+ supportsEffort: resolved.entry?.supportsEffort,
606
+ effortOptions: resolved.entry?.effortOptions,
607
+ thinkingProtocol: resolved.entry?.thinkingProtocol,
608
+ maxBudgetTokens: resolved.entry?.maxBudgetTokens,
609
+ };
610
+ const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
611
+ const sanitized = sanitizeMessagesForWire(filtered);
612
+ try {
613
+ yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext });
614
+ return;
615
+ } catch (err) {
616
+ this.#annotateAuthError(err, provider, params.model);
617
+ if (err?.statusCode !== 401 || refreshedCredential
618
+ || !(await this.#refreshRejectedCredential(provider, params.model))) throw err;
619
+ refreshedCredential = true;
620
+ }
621
+ }
578
622
  }
579
623
 
580
624
  /**
@@ -584,17 +628,28 @@ export class AdapterRouter extends LLMAdapter {
584
628
  * @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number } }>}
585
629
  */
586
630
  async call(params) {
587
- const resolved = await this.#resolveAdapter(params.model);
588
- const effortContext = {
589
- protocol: resolved.protocol,
590
- supportsEffort: resolved.entry?.supportsEffort,
591
- effortOptions: resolved.entry?.effortOptions,
592
- thinkingProtocol: resolved.entry?.thinkingProtocol,
593
- maxBudgetTokens: resolved.entry?.maxBudgetTokens,
594
- };
595
- const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
596
- const sanitized = sanitizeMessagesForWire(filtered);
597
- return resolved.adapter.call({ ...sanitized, model: resolved.modelId, effortContext });
631
+ let refreshedCredential = false;
632
+ while (true) {
633
+ const resolved = await this.#resolveAdapter(params.model);
634
+ const provider = this.getProviderForModel(params.model);
635
+ const effortContext = {
636
+ protocol: resolved.protocol,
637
+ supportsEffort: resolved.entry?.supportsEffort,
638
+ effortOptions: resolved.entry?.effortOptions,
639
+ thinkingProtocol: resolved.entry?.thinkingProtocol,
640
+ maxBudgetTokens: resolved.entry?.maxBudgetTokens,
641
+ };
642
+ const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
643
+ const sanitized = sanitizeMessagesForWire(filtered);
644
+ try {
645
+ return await resolved.adapter.call({ ...sanitized, model: resolved.modelId, effortContext });
646
+ } catch (err) {
647
+ this.#annotateAuthError(err, provider, params.model);
648
+ if (err?.statusCode !== 401 || refreshedCredential
649
+ || !(await this.#refreshRejectedCredential(provider, params.model))) throw err;
650
+ refreshedCredential = true;
651
+ }
652
+ }
598
653
  }
599
654
 
600
655
  /**
@@ -3778,7 +3778,8 @@ function handleEngineEvent(event, hctx) {
3778
3778
  const terminalTurnEnd = event.type === 'turn_end' && event.terminal === true;
3779
3779
  const managesQueryTimer = terminalTurnEnd
3780
3780
  || event.type === 'async_task_wait_start'
3781
- || event.type === 'async_task_wait_end';
3781
+ || event.type === 'async_task_wait_end'
3782
+ || event.type === 'llm_retry';
3782
3783
  if (!managesQueryTimer) hctx.resetQueryTimer();
3783
3784
  const envelope = {
3784
3785
  sessionId: hctx.sessionId,
@@ -4074,6 +4075,9 @@ function handleEngineEvent(event, hctx) {
4074
4075
  break;
4075
4076
 
4076
4077
  case 'llm_retry':
4078
+ // Engine is intentionally sleeping before the next provider request.
4079
+ // Silence watchdogs protect active calls, not declared retry waits.
4080
+ if (typeof hctx.pauseQueryTimer === 'function') hctx.pauseQueryTimer();
4077
4081
  maybeTransitionVpStatus(hctx, 'retrying');
4078
4082
  // Engine paused before re-issuing the same turn because the LLM
4079
4083
  // returned a retryable error (rate limit / 5xx / transient network /
@@ -4284,6 +4288,11 @@ function handleEngineEvent(event, hctx) {
4284
4288
  type: 'error',
4285
4289
  message: visibleErrMsg,
4286
4290
  errorName: event.error?.name || null,
4291
+ statusCode: event.error?.statusCode ?? null,
4292
+ reasonCode: event.error?.reasonCode || null,
4293
+ provider: event.error?.provider || null,
4294
+ model: event.error?.model || null,
4295
+ credentialRefreshable: event.error?.credentialRefreshable === true,
4287
4296
  retryable: !!event.retryable,
4288
4297
  ...(event.reason ? { reason: event.reason } : {}),
4289
4298
  ...(event.retryExhausted !== undefined ? { retryExhausted: !!event.retryExhausted } : {}),