@yeaft/webchat-agent 1.0.578 → 1.0.580

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.
Binary file
@@ -17,6 +17,6 @@
17
17
  </head>
18
18
  <body>
19
19
  <div id="app"></div>
20
- <script type="module" src="app.bundle.js?v=63b14d7d"></script>
20
+ <script type="module" src="app.bundle.js?v=f1badab0"></script>
21
21
  </body>
22
22
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.578",
3
+ "version": "1.0.580",
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
@@ -84,16 +84,18 @@ const DEFAULTS = {
84
84
  // • baseDelayMs / maxDelayMs: exponential backoff bounds used when
85
85
  // the server didn't send a Retry-After header.
86
86
  // • jitterRatio: ± random fraction applied to backoff; 0 disables.
87
- // • streamIdleTimeoutMs: per-SSE-chunk silence budget. 0 disables the
88
- // stalled-stream guard; every received chunk refreshes the budget.
89
- // Keep the default below the normal 120s Session silence watchdog so
90
- // the engine can cancel the stale response and issue a fresh request.
87
+ // • streamIdleTimeoutMs / highEffortStreamIdleTimeoutMs: per-chunk silence
88
+ // budgets, selected from the final wire effort (high/xhigh/max/ultra).
89
+ // Defaults leave 30s before the Session's 120s/300s silence watchdog.
90
+ // streamIdleTimeoutMs: 0 disables both; an explicit legacy timeout sets
91
+ // both budgets unless highEffortStreamIdleTimeoutMs is also supplied.
91
92
  llmRetry: {
92
93
  maxRetries: 3,
93
94
  baseDelayMs: 1_000,
94
95
  maxDelayMs: 30_000,
95
96
  jitterRatio: 0.25,
96
97
  streamIdleTimeoutMs: 90_000,
98
+ highEffortStreamIdleTimeoutMs: 270_000,
97
99
  forbiddenRetryDelaysMs: [30_000, 120_000],
98
100
  },
99
101
  };
@@ -110,7 +112,7 @@ const DEFAULTS = {
110
112
  *
111
113
  * @param {object | null | undefined} fileConfig
112
114
  * @param {object | null | undefined} overrides
113
- * @returns {{ maxRetries: number, baseDelayMs: number, maxDelayMs: number, jitterRatio: number, streamIdleTimeoutMs: number }}
115
+ * @returns {{ maxRetries: number, baseDelayMs: number, maxDelayMs: number, jitterRatio: number, streamIdleTimeoutMs: number, highEffortStreamIdleTimeoutMs: number }}
114
116
  */
115
117
  export function normalizeLlmRetry(fileConfig, overrides) {
116
118
  const base = DEFAULTS.llmRetry;
@@ -131,6 +133,11 @@ export function normalizeLlmRetry(fileConfig, overrides) {
131
133
  }
132
134
  if (Number.isFinite(src.streamIdleTimeoutMs) && src.streamIdleTimeoutMs >= 0) {
133
135
  out.streamIdleTimeoutMs = Math.min(600_000, Math.floor(src.streamIdleTimeoutMs));
136
+ // Preserve explicit legacy budgets (especially 0) at every effort.
137
+ out.highEffortStreamIdleTimeoutMs = out.streamIdleTimeoutMs;
138
+ }
139
+ if (Number.isFinite(src.highEffortStreamIdleTimeoutMs) && src.highEffortStreamIdleTimeoutMs >= 0) {
140
+ out.highEffortStreamIdleTimeoutMs = Math.min(600_000, Math.floor(src.highEffortStreamIdleTimeoutMs));
134
141
  }
135
142
  if (Array.isArray(src.forbiddenRetryDelaysMs)) {
136
143
  out.forbiddenRetryDelaysMs = src.forbiddenRetryDelaysMs
package/yeaft/engine.js CHANGED
@@ -1873,6 +1873,9 @@ export class Engine {
1873
1873
  * string-prompt shape (no regression for existing callers).
1874
1874
  * @param {string|null} [params.causalRootId] - Stable durable identity for
1875
1875
  * every row generated as part of one externally accepted causal root.
1876
+ * @param {Function} [params.onProviderRequestStart] - Internal liveness policy
1877
+ * callback before dispatch: { effort, streamIdleTimeoutMs } from the final
1878
+ * native request. No user content; not persisted or projected to the Web.
1876
1879
  * @yields {EngineEvent}
1877
1880
  */
1878
1881
  async *query(params = {}) {
@@ -1921,7 +1924,7 @@ export class Engine {
1921
1924
  }
1922
1925
  }
1923
1926
 
1924
- async *#queryLifecycle({ prompt, promptParts = null, messages = [], signal, turnConfig = null, userEffort = null, scenario = 'chat', isSubAgent = false, parentEffortDecision = null, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, currentUserMessage = null, causalRootId = null, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null } = {}) {
1927
+ async *#queryLifecycle({ prompt, promptParts = null, messages = [], signal, turnConfig = null, userEffort = null, scenario = 'chat', isSubAgent = false, parentEffortDecision = null, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, currentUserMessage = null, causalRootId = null, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null, onProviderRequestStart = null } = {}) {
1925
1928
  if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
1926
1929
  const error = new Error('prompt is required and must be a non-empty string');
1927
1930
  yield {
@@ -2013,7 +2016,7 @@ export class Engine {
2013
2016
  try {
2014
2017
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
2015
2018
  this.#currentCausalRootId = effectiveCausalRootId;
2016
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, turnConfig: turnConfig ? { model: turnConfig.model, effort: turnConfig.effort, maxOutputTokens: turnConfig.maxOutputTokens } : null, userEffort: explicitUserEffort, scenario, isSubAgent, parentEffortDecision, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds, projectInstruction, projectLabel, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, currentUserMessage, causalRootId: effectiveCausalRootId, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
2019
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, turnConfig: turnConfig ? { model: turnConfig.model, effort: turnConfig.effort, maxOutputTokens: turnConfig.maxOutputTokens } : null, userEffort: explicitUserEffort, scenario, isSubAgent, parentEffortDecision, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds, projectInstruction, projectLabel, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, currentUserMessage, causalRootId: effectiveCausalRootId, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle, onProviderRequestStart });
2017
2020
  } finally {
2018
2021
  // Closing the async generator at a visible retry boundary means the
2019
2022
  // continuation never reached a provider. Keep it out of history and
@@ -2068,7 +2071,7 @@ export class Engine {
2068
2071
  * in a try/finally without indenting the whole loop.
2069
2072
  * @private
2070
2073
  */
2071
- async *#runQuery({ prompt, promptParts = null, messages, signal, turnConfig = null, userEffort = null, scenario = 'chat', isSubAgent = false, parentEffortDecision = null, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, currentUserMessage = null, causalRootId = null, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle }) {
2074
+ async *#runQuery({ prompt, promptParts = null, messages, signal, turnConfig = null, userEffort = null, scenario = 'chat', isSubAgent = false, parentEffortDecision = null, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds = null, projectInstruction = '', projectLabel = '', vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted = false, currentUserMessage = null, causalRootId = null, getCurrentTodos = null, setCurrentTodos = null, askUser = null, threadId = MAIN_THREAD_ID, vpTurnId = null, drainPendingUserMessages = null, prepareProviderRequest = null, startProviderRequest = null, finishProviderRequest = null, failProviderRequest = null, closePendingUserInput = null, collabToolPolicy = null, explicitSkillName = null, retryLifecycle, onProviderRequestStart = null }) {
2072
2075
 
2073
2076
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
2074
2077
  ? collabToolPolicy
@@ -3124,11 +3127,12 @@ export class Engine {
3124
3127
  signal,
3125
3128
  onRawExchange: captureRawExchange,
3126
3129
  rawExchangeMaxBytes,
3127
- onRequestStart: () => {
3130
+ onRequestStart: requestPolicy => {
3128
3131
  // Native adapters invoke this immediately before fetch(). A retry
3129
3132
  // continuation and Work Center EngineTurn become durable only when
3130
3133
  // their request crosses dispatch, never when turn_start is shown.
3131
3134
  commitDispatch();
3135
+ onProviderRequestStart?.(requestPolicy);
3132
3136
  },
3133
3137
  });
3134
3138
  yield { type: 'turn_start', turnId: queryTurnId, turnNumber, threadId };
@@ -310,6 +310,28 @@ export class SseLineBuffer {
310
310
  }
311
311
  }
312
312
 
313
+ /**
314
+ * Resolve one request's transport silence budget after final effort enforcement.
315
+ * Provider/model overrides are absolute (including 0); the shared adapter is
316
+ * never mutated. Explicit legacy timeouts apply to every effort unless a high
317
+ * budget was also configured. All enabled budgets remain bounded at 10 minutes.
318
+ *
319
+ * @param {{ streamIdleTimeoutMs?: number, highEffortStreamIdleTimeoutMs?: number }} policy
320
+ * @param {string|null} effort — effective wire effort, not the requested value
321
+ * @param {number|undefined} overrideMs — model > provider override
322
+ * @returns {number}
323
+ */
324
+ export function resolveStreamIdleTimeoutMs(policy = {}, effort = null, overrideMs) {
325
+ const valid = value => Number.isFinite(value) && value >= 0;
326
+ const clamp = value => Math.min(600_000, Math.floor(value));
327
+ if (valid(overrideMs)) return clamp(overrideMs);
328
+ const normal = valid(policy.streamIdleTimeoutMs) ? clamp(policy.streamIdleTimeoutMs) : 0;
329
+ if (normal === 0) return 0;
330
+ const high = ['high', 'xhigh', 'max', 'ultra'].includes(effort);
331
+ return high && valid(policy.highEffortStreamIdleTimeoutMs)
332
+ ? clamp(policy.highEffortStreamIdleTimeoutMs) : normal;
333
+ }
334
+
313
335
  /**
314
336
  * Read one chunk from a Fetch stream with a silence timeout. This is not a
315
337
  * total request deadline: every received chunk gets a fresh budget. A caller
@@ -692,6 +714,7 @@ export async function createLLMAdapter(config) {
692
714
  apiKey: config.apiKey,
693
715
  baseUrl: config.baseUrl || undefined, // AnthropicAdapter has its own default
694
716
  streamIdleTimeoutMs: config.llmRetry?.streamIdleTimeoutMs,
717
+ highEffortStreamIdleTimeoutMs: config.llmRetry?.highEffortStreamIdleTimeoutMs,
695
718
  });
696
719
  }
697
720
 
@@ -18,6 +18,7 @@ import {
18
18
  LLMServerError,
19
19
  LLMAbortError,
20
20
  readStreamChunkWithIdleTimeout,
21
+ resolveStreamIdleTimeoutMs,
21
22
  redactRawRequest,
22
23
  safeHeaders,
23
24
  SseLineBuffer,
@@ -71,6 +72,13 @@ function hasNonEmptyText(value) {
71
72
  return typeof value === 'string' && value.trim().length > 0;
72
73
  }
73
74
 
75
+ function requireToolInputObject(input) {
76
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
77
+ throw new LLMServerError('Anthropic returned invalid or incomplete tool input', 0);
78
+ }
79
+ return input;
80
+ }
81
+
74
82
  function translateUserContent(content) {
75
83
  if (hasNonEmptyText(content)) return content;
76
84
 
@@ -102,17 +110,17 @@ export class AnthropicAdapter extends LLMAdapter {
102
110
  #apiKey;
103
111
  #baseUrl;
104
112
  #authHeaderMode;
105
- #streamIdleTimeoutMs;
113
+ #streamIdlePolicy;
106
114
 
107
115
  /**
108
- * @param {{ apiKey: string, baseUrl?: string, authHeaderMode?: 'x-api-key'|'bearer', streamIdleTimeoutMs?: number }} config
116
+ * @param {{ apiKey: string, baseUrl?: string, authHeaderMode?: 'x-api-key'|'bearer', streamIdleTimeoutMs?: number, highEffortStreamIdleTimeoutMs?: number }} config
109
117
  */
110
- constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, authHeaderMode = 'x-api-key', streamIdleTimeoutMs = 0 }) {
111
- super({ apiKey, baseUrl, streamIdleTimeoutMs });
118
+ constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, authHeaderMode = 'x-api-key', streamIdleTimeoutMs = 0, highEffortStreamIdleTimeoutMs }) {
119
+ super({ apiKey, baseUrl, streamIdleTimeoutMs, highEffortStreamIdleTimeoutMs });
112
120
  this.#apiKey = apiKey;
113
121
  this.#baseUrl = baseUrl;
114
122
  this.#authHeaderMode = authHeaderMode === 'bearer' ? 'bearer' : 'x-api-key';
115
- this.#streamIdleTimeoutMs = Number.isFinite(streamIdleTimeoutMs) ? Math.max(0, Math.floor(streamIdleTimeoutMs)) : 0;
123
+ this.#streamIdlePolicy = { streamIdleTimeoutMs, highEffortStreamIdleTimeoutMs };
116
124
  }
117
125
 
118
126
  #headers() {
@@ -133,12 +141,15 @@ export class AnthropicAdapter extends LLMAdapter {
133
141
  * @param {import('./adapter.js').UnifiedToolDef[]} tools
134
142
  * @returns {object[]}
135
143
  */
136
- #translateTools(tools) {
144
+ #translateTools(tools, context) {
137
145
  if (!tools || tools.length === 0) return undefined;
138
146
  return tools.map(t => ({
139
147
  name: t.name,
140
148
  description: t.description,
141
149
  input_schema: t.parameters,
150
+ // Native API supports per-tool streaming without the legacy beta header.
151
+ // Unknown/translation gateways must explicitly opt in after verification.
152
+ ...(context?.capabilities?.eagerInputStreaming ? { eager_input_streaming: true } : {}),
142
153
  }));
143
154
  }
144
155
 
@@ -238,7 +249,7 @@ export class AnthropicAdapter extends LLMAdapter {
238
249
  * @param {{ model: string, system: string, messages: import('./adapter.js').UnifiedMessage[], tools?: import('./adapter.js').UnifiedToolDef[], maxTokens?: number, effort?: 'low'|'medium'|'high'|'xhigh'|'max', effortSource?: 'user'|'auto', effortContext?: object, signal?: AbortSignal, onRawExchange?: ({rawRequest, rawResponse}) => void }} params
239
250
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
240
251
  */
241
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext, extraBody, providerContext, requestIdentity, onProviderDiagnostics, effortConstraint = null, onEffortDecision = null, signal, onRawExchange, rawExchangeMaxBytes = 512 * 1024, onRequestStart }) {
252
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext, extraBody, providerContext, requestIdentity, onProviderDiagnostics, effortConstraint = null, onEffortDecision = null, signal, onRawExchange, rawExchangeMaxBytes = 512 * 1024, onRequestStart, streamIdleTimeoutMs }) {
242
253
  if (signal?.aborted) throw new LLMAbortError();
243
254
 
244
255
  const context = providerContext || createProviderContext({ protocol: 'anthropic', baseUrl: this.#baseUrl, model });
@@ -259,7 +270,7 @@ export class AnthropicAdapter extends LLMAdapter {
259
270
  applyAnthropicThinking(body, model, normEffort, effortContext);
260
271
  }
261
272
 
262
- const translatedTools = this.#translateTools(tools);
273
+ const translatedTools = this.#translateTools(tools, context);
263
274
  if (translatedTools) body.tools = translatedTools;
264
275
  if (extraBody) Object.assign(body, extraBody);
265
276
  body.model = model; // Do not let extraBody bypass origin/model ownership.
@@ -271,6 +282,7 @@ export class AnthropicAdapter extends LLMAdapter {
271
282
  : captureEffortDecision({ body, model, protocol: 'anthropic', effortContext, requested: effort, source: effortSource || 'scenario' });
272
283
  onEffortDecision?.(effortDecision);
273
284
  const wireBody = toWellFormedJson(body);
285
+ const idleMs = resolveStreamIdleTimeoutMs(this.#streamIdlePolicy, effortDecision.effective, streamIdleTimeoutMs);
274
286
 
275
287
  const url = `${this.#baseUrl}/v1/messages`;
276
288
  const headers = this.#headers();
@@ -282,7 +294,7 @@ export class AnthropicAdapter extends LLMAdapter {
282
294
 
283
295
  let response;
284
296
  try {
285
- onRequestStart?.();
297
+ onRequestStart?.({ effort: effortDecision.effective, streamIdleTimeoutMs: idleMs });
286
298
  response = await fetch(url, {
287
299
  method: 'POST',
288
300
  headers,
@@ -315,6 +327,19 @@ export class AnthropicAdapter extends LLMAdapter {
315
327
  // when streaming was requested. Seal exactly the same native blocks.
316
328
  if ((response.headers?.get('content-type') || '').includes('application/json')) {
317
329
  const result = await response.json();
330
+ // Account for consumed tokens even when the completed response cannot
331
+ // safely publish tools. Budget consumers may abort on this usage event.
332
+ yield { type: 'usage', inputTokens: result.usage?.input_tokens || 0, outputTokens: result.usage?.output_tokens || 0,
333
+ cacheReadTokens: result.usage?.cache_read_input_tokens || 0, cacheWriteTokens: result.usage?.cache_creation_input_tokens || 0,
334
+ ...reasoningUsage(result.usage, 'anthropic') };
335
+ if (signal?.aborted) throw new LLMAbortError();
336
+ for (const block of result.content || []) {
337
+ if (block.type !== 'tool_use') continue;
338
+ requireToolInputObject(block.input);
339
+ if (result.stop_reason === 'max_tokens') {
340
+ throw new LLMServerError('Anthropic tool response was truncated at max_tokens', 0);
341
+ }
342
+ }
318
343
  const state = createProviderState({ context, identity: requestIdentity, items: result.content, responseId: result.id });
319
344
  for (const block of result.content || []) {
320
345
  if (block.type === 'text') yield { type: 'text_delta', text: block.text };
@@ -323,9 +348,6 @@ export class AnthropicAdapter extends LLMAdapter {
323
348
  if (block.type === 'redacted_thinking') yield { type: 'thinking_block_end', redacted: true, data: block.data };
324
349
  }
325
350
  if (state) yield { type: 'provider_state', providerState: state, providerStateBytes: providerStateBytes(state) };
326
- yield { type: 'usage', inputTokens: result.usage?.input_tokens || 0, outputTokens: result.usage?.output_tokens || 0,
327
- cacheReadTokens: result.usage?.cache_read_input_tokens || 0, cacheWriteTokens: result.usage?.cache_creation_input_tokens || 0,
328
- ...reasoningUsage(result.usage, 'anthropic') };
329
351
  yield { type: 'stop', stopReason: this.#mapStopReason(result.stop_reason) };
330
352
  if (onRawExchange) {
331
353
  try { onRawExchange({ rawRequest, rawResponse: { status: response.status, headers: safeHeaders(response), body: result } }); } catch { /* diagnostic only */ }
@@ -365,12 +387,20 @@ export class AnthropicAdapter extends LLMAdapter {
365
387
  let sawStop = false;
366
388
  let sawMessageStart = false;
367
389
  let cumulativeOutputTokens = 0;
390
+ let toolInputError = null;
391
+ const hasToolBlocks = () => toolInputError !== null || [...blockByIndex.values()].some(block => block.kind === 'tool_use')
392
+ || [...completedBlocks.values()].some(block => block.type === 'tool_use');
393
+ const requireClosedBlocks = () => {
394
+ if (blockByIndex.size > 0) {
395
+ throw new LLMServerError('Anthropic stream ended with an incomplete content block', 0);
396
+ }
397
+ };
368
398
 
369
399
  try {
370
- while (true) {
400
+ while (!sawStop) {
371
401
  const { done, value } = await readStreamChunkWithIdleTimeout(reader, {
372
402
  signal,
373
- idleMs: this.#streamIdleTimeoutMs,
403
+ idleMs,
374
404
  providerLabel: 'Anthropic',
375
405
  });
376
406
  if (done) break;
@@ -380,9 +410,17 @@ export class AnthropicAdapter extends LLMAdapter {
380
410
  const lines = sseLines.push(chunkText);
381
411
 
382
412
  for (const line of lines) {
383
- if (!line.startsWith('data: ')) continue;
384
- const data = line.slice(6).trim();
413
+ // SSE permits `data:{...}` as well as `data: {...}`. Skipping the
414
+ // compact form can lose input deltas or the max_tokens stop reason.
415
+ if (!line.startsWith('data:')) continue;
416
+ const data = line.slice(5).trim();
417
+ if (signal?.aborted) throw new LLMAbortError();
418
+ if (sawStop) continue;
385
419
  if (data === '[DONE]') {
420
+ // Legacy text-only gateways may use [DONE]. Tools require the
421
+ // native message_stop boundary; never seal a partial batch here.
422
+ if (hasToolBlocks()) throw new LLMServerError('Anthropic stream ended before stop event', 0);
423
+ requireClosedBlocks();
386
424
  sawStop = true;
387
425
  continue;
388
426
  }
@@ -391,12 +429,12 @@ export class AnthropicAdapter extends LLMAdapter {
391
429
  try {
392
430
  event = JSON.parse(data);
393
431
  } catch {
394
- continue;
432
+ // A lost input delta can otherwise turn the start placeholder {}
433
+ // into executable arguments. Do not silently skip malformed SSE.
434
+ throw new LLMServerError('Anthropic returned a malformed stream event', 0);
395
435
  }
396
436
 
397
- const type = event.type;
398
- if (signal?.aborted) throw new LLMAbortError();
399
- if (sawStop) continue;
437
+ const type = event?.type;
400
438
 
401
439
  if (type === 'content_block_start') {
402
440
  const block = event.content_block;
@@ -466,20 +504,16 @@ export class AnthropicAdapter extends LLMAdapter {
466
504
  // Unknown / unhandled block kind (e.g. text — we don't track
467
505
  // text state because text_delta is forwarded immediately).
468
506
  } else if (st.kind === 'tool_use') {
469
- let parsedInput = {};
470
507
  try {
471
- parsedInput = st.input ? JSON.parse(st.input) : st.native.input || {};
508
+ const parsedInput = requireToolInputObject(st.input ? JSON.parse(st.input) : st.native.input);
509
+ completedBlocks.set(idx, { ...st.native, input: parsedInput });
472
510
  } catch {
473
- parsedInput = {};
474
- stateFailed = true;
511
+ // Reject the entire batch, but drain to the message boundary so
512
+ // the provider's final usage is not lost on truncated JSON.
513
+ toolInputError = new LLMServerError('Anthropic returned invalid or incomplete tool input', 0);
475
514
  }
476
- completedBlocks.set(idx, { ...st.native, input: parsedInput });
477
- yield {
478
- type: 'tool_call',
479
- id: st.id,
480
- name: st.name,
481
- input: parsedInput,
482
- };
515
+ // Publish tools only after message_stop validates the entire
516
+ // response, including parallel siblings and max_tokens truncation.
483
517
  } else if (st.kind === 'thinking' || st.kind === 'redacted_thinking') {
484
518
  // task-327d: emit ONE end-of-block event with the assembled
485
519
  // payload + signature. Engine collects these for replay.
@@ -505,15 +539,8 @@ export class AnthropicAdapter extends LLMAdapter {
505
539
  if (st?.kind === 'redacted_thinking') completedBlocks.set(idx, { type: 'redacted_thinking', data: st.data });
506
540
  blockByIndex.delete(idx);
507
541
  } else if (type === 'message_delta') {
508
- const stopReason = event.delta?.stop_reason;
509
- if (stopReason) {
510
- // Only message_stop seals native state. EOF after message_delta
511
- // must not silently complete a signed tool turn without its state.
512
- yield {
513
- type: 'stop',
514
- stopReason: this.#mapStopReason(stopReason),
515
- };
516
- }
542
+ // Account before validating stop_reason: failed requests still cost
543
+ // tokens, and usage can trigger a budget abort before any retry.
517
544
  // Anthropic message_delta usage is cumulative across the response.
518
545
  // Expose only the newly consumed output tokens so shared accounting
519
546
  // can safely add events from message_start and multiple deltas.
@@ -534,11 +561,27 @@ export class AnthropicAdapter extends LLMAdapter {
534
561
  outputTokens,
535
562
  };
536
563
  }
564
+ if (signal?.aborted) throw new LLMAbortError();
565
+ const stopReason = event.delta?.stop_reason;
566
+ if (stopReason === 'max_tokens' && hasToolBlocks()) {
567
+ toolInputError = new LLMServerError('Anthropic tool response was truncated at max_tokens', 0);
568
+ }
569
+ if (stopReason && !toolInputError) {
570
+ // Only message_stop seals native state. EOF after message_delta
571
+ // must not silently complete a signed tool turn without its state.
572
+ yield { type: 'stop', stopReason: this.#mapStopReason(stopReason) };
573
+ }
537
574
  } else if (type === 'message_stop') {
575
+ if (toolInputError) throw toolInputError;
576
+ requireClosedBlocks();
538
577
  sawStop = true;
539
- if (!stateFailed && blockByIndex.size === 0) {
540
- const state = createProviderState({ context, identity: requestIdentity, responseId,
541
- items: [...completedBlocks].sort(([a], [b]) => a - b).map(([, block]) => block) });
578
+ if (!stateFailed) {
579
+ const items = [...completedBlocks].sort(([a], [b]) => a - b).map(([, block]) => block);
580
+ const state = createProviderState({ context, identity: requestIdentity, responseId, items });
581
+ for (const block of items) {
582
+ if (signal?.aborted) throw new LLMAbortError();
583
+ if (block.type === 'tool_use') yield { type: 'tool_call', id: block.id, name: block.name, input: block.input };
584
+ }
542
585
  if (state) yield { type: 'provider_state', providerState: state, providerStateBytes: providerStateBytes(state) };
543
586
  }
544
587
  } else if (type === 'message_start') {
@@ -571,12 +614,18 @@ export class AnthropicAdapter extends LLMAdapter {
571
614
  }
572
615
  }
573
616
  }
574
- if (sawMessageStart && !sawStop) {
617
+ if (signal?.aborted) throw new LLMAbortError();
618
+ if (toolInputError) throw toolInputError;
619
+ if (!sawStop && (sawMessageStart || hasToolBlocks())) {
575
620
  throw new LLMServerError('Anthropic stream ended before stop event', 0);
576
621
  }
622
+ requireClosedBlocks();
577
623
  } catch (err) {
578
624
  throw classifyFetchError(err, { providerLabel: 'Anthropic', signal });
579
625
  } finally {
626
+ // Close malformed/aborted streams and transports that stay open after
627
+ // message_stop. Cancellation must not delay the caller or mask its error.
628
+ try { Promise.resolve(reader.cancel()).catch(() => {}); } catch { /* best-effort */ }
580
629
  reader.releaseLock();
581
630
  // Emit raw exchange after stream completes (or errors). Body is the
582
631
  // verbatim SSE — never truncated.
@@ -72,8 +72,24 @@ export function modelEntryForGitHubCopilot(id) {
72
72
  return protocol ? { id: value, protocol } : { id: value };
73
73
  }
74
74
 
75
+ // Managed catalogs still own model protocols and credentials. Preserve only
76
+ // supported, non-secret request-policy overrides across normalization and save.
77
+ function requestPolicyOverrides(entry) {
78
+ const out = {};
79
+ if (entry?.capabilities && typeof entry.capabilities === 'object' && !Array.isArray(entry.capabilities)) {
80
+ out.capabilities = { ...entry.capabilities };
81
+ }
82
+ if (Number.isFinite(entry?.streamIdleTimeoutMs) && entry.streamIdleTimeoutMs >= 0) {
83
+ out.streamIdleTimeoutMs = Math.min(600_000, Math.floor(entry.streamIdleTimeoutMs));
84
+ }
85
+ return out;
86
+ }
87
+
75
88
  export function githubCopilotModelEntries(ids = FALLBACK_GITHUB_COPILOT_MODELS) {
76
- return dedupe(ids).map(item => modelEntryForGitHubCopilot(modelId(item))).filter(Boolean);
89
+ return dedupe(ids).map(item => ({
90
+ ...modelEntryForGitHubCopilot(modelId(item)),
91
+ ...requestPolicyOverrides(item),
92
+ }));
77
93
  }
78
94
 
79
95
  export function normalizeKnownProviderForRuntime(provider) {
@@ -100,6 +116,7 @@ export function serializeKnownProviderForPersistence(provider) {
100
116
  name: provider.name || GITHUB_COPILOT_PROVIDER_NAME,
101
117
  credentialProvider: GITHUB_COPILOT_CREDENTIAL_PROVIDER,
102
118
  managed: provider.managed || GITHUB_COPILOT_CREDENTIAL_PROVIDER,
119
+ ...requestPolicyOverrides(provider),
103
120
  ...(models.length ? { models } : {}),
104
121
  };
105
122
  }
@@ -38,6 +38,7 @@ import {
38
38
  classifyFetchError,
39
39
  retryAfterFromResponse,
40
40
  readStreamChunkWithIdleTimeout,
41
+ resolveStreamIdleTimeoutMs,
41
42
  redactRawRequest,
42
43
  safeHeaders,
43
44
  SseLineBuffer,
@@ -76,16 +77,16 @@ function effortForResponses(effort) {
76
77
  export class OpenAIResponsesAdapter extends LLMAdapter {
77
78
  #apiKey;
78
79
  #baseUrl;
79
- #streamIdleTimeoutMs;
80
+ #streamIdlePolicy;
80
81
 
81
82
  /**
82
- * @param {{ apiKey: string, baseUrl?: string, streamIdleTimeoutMs?: number }} config
83
+ * @param {{ apiKey: string, baseUrl?: string, streamIdleTimeoutMs?: number, highEffortStreamIdleTimeoutMs?: number }} config
83
84
  */
84
- constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, streamIdleTimeoutMs = 0 }) {
85
- super({ apiKey, baseUrl, streamIdleTimeoutMs });
85
+ constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, streamIdleTimeoutMs = 0, highEffortStreamIdleTimeoutMs }) {
86
+ super({ apiKey, baseUrl, streamIdleTimeoutMs, highEffortStreamIdleTimeoutMs });
86
87
  this.#apiKey = apiKey;
87
88
  this.#baseUrl = (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
88
- this.#streamIdleTimeoutMs = Number.isFinite(streamIdleTimeoutMs) ? Math.max(0, Math.floor(streamIdleTimeoutMs)) : 0;
89
+ this.#streamIdlePolicy = { streamIdleTimeoutMs, highEffortStreamIdleTimeoutMs };
89
90
  }
90
91
 
91
92
  /** Expose baseUrl for testing. */
@@ -268,7 +269,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
268
269
  * `api-key` headers are auto-redacted (see `redactRawRequest` in
269
270
  * `adapter.js`); request-body fields are caller-controlled.
270
271
  */
271
- async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext = {}, extraBody, providerContext, requestIdentity, onProviderDiagnostics, effortConstraint = null, onEffortDecision = null, signal, onRawExchange, rawExchangeMaxBytes = 512 * 1024, onRequestStart }) {
272
+ async *stream({ model, system, messages, tools, maxTokens = 16384, effort, effortSource, effortContext = {}, extraBody, providerContext, requestIdentity, onProviderDiagnostics, effortConstraint = null, onEffortDecision = null, signal, onRawExchange, rawExchangeMaxBytes = 512 * 1024, onRequestStart, streamIdleTimeoutMs }) {
272
273
  if (signal?.aborted) throw new LLMAbortError();
273
274
 
274
275
  const context = providerContext || createProviderContext({ protocol: 'openai-responses', baseUrl: this.#baseUrl, model });
@@ -305,6 +306,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
305
306
  : captureEffortDecision({ body, model, protocol: 'openai-responses', effortContext, requested: effort, source: effortSource || 'scenario' });
306
307
  onEffortDecision?.(effortDecision);
307
308
  const wireBody = toWellFormedJson(body);
309
+ const idleMs = resolveStreamIdleTimeoutMs(this.#streamIdlePolicy, effortDecision.effective, streamIdleTimeoutMs);
308
310
 
309
311
  const url = `${this.#baseUrl}/responses`;
310
312
  const headers = {
@@ -318,7 +320,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
318
320
 
319
321
  let response;
320
322
  try {
321
- onRequestStart?.();
323
+ onRequestStart?.({ effort: effortDecision.effective, streamIdleTimeoutMs: idleMs });
322
324
  response = await fetch(url, {
323
325
  method: 'POST',
324
326
  headers,
@@ -402,7 +404,7 @@ export class OpenAIResponsesAdapter extends LLMAdapter {
402
404
  while (true) {
403
405
  const { done, value } = await readStreamChunkWithIdleTimeout(reader, {
404
406
  signal,
405
- idleMs: this.#streamIdleTimeoutMs,
407
+ idleMs,
406
408
  providerLabel: 'OpenAI',
407
409
  });
408
410
  if (done) break;
@@ -75,6 +75,7 @@ export function createProviderContext({ protocol, baseUrl, providerId, credentia
75
75
  nativeReasoningState: enabled('nativeReasoningState'),
76
76
  promptCaching: enabled('promptCaching'),
77
77
  parallelToolCalls: enabled('parallelToolCalls'),
78
+ eagerInputStreaming: protocol === 'anthropic' && enabled('eagerInputStreaming'),
78
79
  },
79
80
  diagnostics: { capabilitySource: capabilities.translation ? 'translation-disabled' : native ? 'native-endpoint' : 'custom-endpoint-unverified' },
80
81
  };
@@ -48,6 +48,9 @@ export function normalizeModelEntry(entry) {
48
48
  if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) {
49
49
  const out = { id: entry.id };
50
50
  if (entry.capabilities && typeof entry.capabilities === 'object') out.capabilities = { ...entry.capabilities };
51
+ if (Number.isFinite(entry.streamIdleTimeoutMs) && entry.streamIdleTimeoutMs >= 0) {
52
+ out.streamIdleTimeoutMs = Math.min(600_000, Math.floor(entry.streamIdleTimeoutMs));
53
+ }
51
54
  if (typeof entry.protocol === 'string' && entry.protocol) {
52
55
  out.protocol = entry.protocol;
53
56
  }
@@ -246,18 +249,19 @@ export class AdapterRouter extends LLMAdapter {
246
249
  /** @type {Set<string>} managed providers backed by an explicit model catalog */
247
250
  #authoritativeManagedProviders;
248
251
 
249
- /** @type {number} per-SSE-chunk silence budget; <= 0 disables the guard */
250
- #streamIdleTimeoutMs;
252
+ /** @type {{ streamIdleTimeoutMs?: number, highEffortStreamIdleTimeoutMs?: number }} */
253
+ #streamIdlePolicy;
251
254
 
252
255
  /**
253
- * @param {{ providers: object[], llmRetry?: { streamIdleTimeoutMs?: number } }} params
256
+ * @param {{ providers: object[], llmRetry?: { streamIdleTimeoutMs?: number, highEffortStreamIdleTimeoutMs?: number } }} params
254
257
  * @param {object[]} params.providers — Array of { name, baseUrl, apiKey, protocol?, models[] }
255
258
  */
256
259
  constructor({ providers, llmRetry = {} }) {
257
260
  super();
258
- this.#streamIdleTimeoutMs = Number.isFinite(llmRetry.streamIdleTimeoutMs)
259
- ? Math.max(0, Math.floor(llmRetry.streamIdleTimeoutMs))
260
- : 0;
261
+ this.#streamIdlePolicy = {
262
+ streamIdleTimeoutMs: llmRetry.streamIdleTimeoutMs,
263
+ highEffortStreamIdleTimeoutMs: llmRetry.highEffortStreamIdleTimeoutMs,
264
+ };
261
265
  this.#providers = [];
262
266
  this.#modelToProvider = new Map();
263
267
  this.#adapterCache = new Map();
@@ -509,7 +513,7 @@ export class AdapterRouter extends LLMAdapter {
509
513
  apiKey,
510
514
  baseUrl: provider.baseUrl,
511
515
  authHeaderMode: anthropicAuthHeaderMode,
512
- streamIdleTimeoutMs: this.#streamIdleTimeoutMs,
516
+ ...this.#streamIdlePolicy,
513
517
  });
514
518
  } else if (protocol === 'openai-responses') {
515
519
  // OpenAI Responses API (/v1/responses) — canonical OpenAI-compatible path.
@@ -517,7 +521,7 @@ export class AdapterRouter extends LLMAdapter {
517
521
  adapter = new OpenAIResponsesAdapter({
518
522
  apiKey,
519
523
  baseUrl: provider.baseUrl,
520
- streamIdleTimeoutMs: this.#streamIdleTimeoutMs,
524
+ ...this.#streamIdlePolicy,
521
525
  });
522
526
  } else {
523
527
  throw new Error(
@@ -659,8 +663,13 @@ export class AdapterRouter extends LLMAdapter {
659
663
  };
660
664
  const filtered = filterEffortForModel({ ...params, model: resolved.modelId }, resolved);
661
665
  const sanitized = sanitizeMessagesForWire(filtered);
666
+ // Resolve the absolute model > provider override per request, never on
667
+ // the shared adapter: two models may use the same cached instance.
668
+ const configuredTimeout = [resolved.entry?.streamIdleTimeoutMs, provider?.streamIdleTimeoutMs]
669
+ .find(value => Number.isFinite(value) && value >= 0);
662
670
  try {
663
- yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext, providerContext, rawExchangeMaxBytes: params.rawExchangeMaxBytes });
671
+ yield* resolved.adapter.stream({ ...sanitized, model: resolved.modelId, effortContext, providerContext,
672
+ streamIdleTimeoutMs: configuredTimeout, rawExchangeMaxBytes: params.rawExchangeMaxBytes });
664
673
  return;
665
674
  } catch (err) {
666
675
  this.#annotateAuthError(err, provider, params.model);
@@ -71,9 +71,9 @@ export class UsageAccountingAdapter extends LLMAdapter {
71
71
  const upstream = params?.onRequestStart;
72
72
  return {
73
73
  ...params,
74
- onRequestStart: () => {
74
+ onRequestStart: (...args) => {
75
75
  try {
76
- upstream?.();
76
+ upstream?.(...args);
77
77
  } finally {
78
78
  try {
79
79
  this.#onRequest();