@yeaft/webchat-agent 1.0.379 → 1.0.381

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
@@ -16,6 +16,6 @@
16
16
  </head>
17
17
  <body>
18
18
  <div id="app"></div>
19
- <script type="module" src="app.bundle.js?v=334e9e23"></script>
19
+ <script type="module" src="app.bundle.js?v=22a8b637"></script>
20
20
  </body>
21
21
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.379",
3
+ "version": "1.0.381",
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/engine.js CHANGED
@@ -2073,7 +2073,8 @@ export class Engine {
2073
2073
  // openai-responses.js:#translateUserContent).
2074
2074
 
2075
2075
  // task-327b: `/max` / `/high` / `/medium` / `/low` prefix override.
2076
- // Explicit caller-supplied userEffort wins over the prefix.
2076
+ // Explicit caller-supplied userEffort wins over the prefix. Session config
2077
+ // is deliberately excluded here because it may refresh between loops.
2077
2078
  // task-327c nit: defensively normalize caller-supplied userEffort BEFORE
2078
2079
  // the merge, so an invalid caller value (e.g. 'ULTRA') does not shadow a
2079
2080
  // valid prompt prefix.
@@ -2083,8 +2084,10 @@ export class Engine {
2083
2084
  const effectivePromptParts = parsedSkill.skillName
2084
2085
  ? stripLeadingSkillCommandFromPromptParts(promptParts, this.#skillManager)
2085
2086
  : promptParts;
2086
- const configuredEffort = normalizeEffort(this.#config?.modelEffort);
2087
- const effectiveUserEffort = normalizeEffort(userEffort) || parsed.effort || configuredEffort || null;
2087
+ // Only actual caller input and a prompt prefix are per-query overrides.
2088
+ // Session-configured effort belongs to the live config and is resolved at
2089
+ // each provider-request boundary, just like the live model snapshot.
2090
+ const explicitUserEffort = normalizeEffort(userEffort) || parsed.effort || null;
2088
2091
  const effectiveCollabToolPolicy = collabToolPolicy === COLLAB_TOOL_POLICY.SINGLE_VP || collabToolPolicy === COLLAB_TOOL_POLICY.MULTI_VP
2089
2092
  ? collabToolPolicy
2090
2093
  : null;
@@ -2131,7 +2134,7 @@ export class Engine {
2131
2134
  };
2132
2135
  try {
2133
2136
  this.#currentThreadId = threadId || MAIN_THREAD_ID;
2134
- yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds, projectInstruction, projectLabel, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
2137
+ yield* this.#runQuery({ prompt: effectivePrompt, promptParts: effectivePromptParts, messages, signal: runSignal, userEffort: explicitUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, sessionId, sessionMembers, projectSessionIds, projectInstruction, projectLabel, vpPlan, sessionAnnouncement, workCenterInstructions, workDir, userAlreadyPersisted, getCurrentTodos, setCurrentTodos, askUser, threadId: this.#currentThreadId, vpTurnId, drainPendingUserMessages, prepareProviderRequest, startProviderRequest, finishProviderRequest, failProviderRequest, closePendingUserInput, collabToolPolicy: effectiveCollabToolPolicy, explicitSkillName: parsedSkill.skillName, retryLifecycle });
2135
2138
  } finally {
2136
2139
  // Closing the async generator at a visible retry boundary means the
2137
2140
  // continuation never reached a provider. Keep it out of history and
@@ -2697,7 +2700,11 @@ export class Engine {
2697
2700
  let displayImageAnchorMessage = null;
2698
2701
  let lastPersistedAssistantMessage = null;
2699
2702
  let lastPersistedAssistantTextMessage = null;
2703
+ // `refreshConfig()` may publish a new Session model while a stream or a
2704
+ // tool is running. Apply it only before the next provider request; the
2705
+ // current request keeps the snapshot captured below.
2700
2706
  let currentModel = this.#config.model;
2707
+ let primaryModelAtLastBoundary = currentModel;
2701
2708
  let cumulativeInputTokens = 0;
2702
2709
  let cumulativeOutputTokens = 0;
2703
2710
  let activeProviderRequest = null;
@@ -2722,13 +2729,39 @@ export class Engine {
2722
2729
  // gives up: we either fall back to a backup model or surface the
2723
2730
  // error to the user. LLMContextError has its own compact-retry path
2724
2731
  // and does NOT count against this budget.
2725
- const retryPolicy = resolveRetryPolicy(this.#config);
2732
+ let retryPolicy = resolveRetryPolicy(this.#config);
2726
2733
  let consecutiveRetryableErrors = 0;
2727
2734
  let consecutiveForbiddenErrors = 0;
2728
2735
 
2729
2736
  while (true) {
2730
2737
  turnNumber++;
2731
2738
 
2739
+ // `refreshConfig()` is called by the bridge after a persisted Session or
2740
+ // Agent config update. This is the only point a running query adopts the
2741
+ // new primary model, so an in-flight provider stream is never switched.
2742
+ // Keep a retry fallback selected by this query; replacing it here would
2743
+ // turn an exhausted primary into an endless retry loop.
2744
+ if (currentModel === primaryModelAtLastBoundary) {
2745
+ const refreshedPrimaryModel = this.#config.model;
2746
+ if (refreshedPrimaryModel !== primaryModelAtLastBoundary) {
2747
+ currentModel = refreshedPrimaryModel;
2748
+ primaryModelAtLastBoundary = refreshedPrimaryModel;
2749
+ }
2750
+ }
2751
+
2752
+ // Take one immutable runtime snapshot before any request-specific work.
2753
+ // A Session save racing preflight must be picked up by the next loop, not
2754
+ // this request. Fallback retries intentionally retain their selected
2755
+ // model, but still use the current policy and configured effort.
2756
+ const requestConfig = { ...this.#config };
2757
+ // Capture the matching provider catalog in the same synchronous boundary
2758
+ // as config/model. Preflight may yield user/task events before the stream
2759
+ // is built, but one request must never mix two refresh revisions.
2760
+ const requestAdapter = typeof this.#adapter.captureRequest === 'function'
2761
+ ? this.#adapter.captureRequest()
2762
+ : this.#adapter;
2763
+ retryPolicy = resolveRetryPolicy(requestConfig);
2764
+
2732
2765
  // task-324: no hard MAX_TURNS cap. Loop terminates on end_turn,
2733
2766
  // non-retryable error, LLMContextError (after compact retry), or
2734
2767
  // caller abort. Keeping this comment so the removal is traceable.
@@ -2825,9 +2858,7 @@ export class Engine {
2825
2858
  // actually about to call. Single resolver in models.js owns the
2826
2859
  // fallback ladder (registry → config → default) so engine.js and
2827
2860
  // tools/registry.js can never disagree.
2828
- const currentContextWindow = resolveContextWindow(currentModel, this.#config);
2829
-
2830
- yield { type: 'turn_start', turnNumber, threadId };
2861
+ const currentContextWindow = resolveContextWindow(currentModel, requestConfig);
2831
2862
 
2832
2863
  const appendedBeforeStream = this.#drainPendingUserMessages(drainPendingUserMessages);
2833
2864
  if (appendedBeforeStream.length > 0) {
@@ -2887,9 +2918,12 @@ export class Engine {
2887
2918
  systemPrompt = buildCurrentSystemPrompt();
2888
2919
 
2889
2920
  try {
2890
- // task-327b: resolve effort per-turn so the long-loop auto-bump
2891
- // kicks in once toolLoopTurns crosses the threshold.
2892
- let resolvedEffort = pickEffort({ scenario, toolLoopTurns, userEffort });
2921
+ // Resolve effort per provider request so a saved Session effort takes
2922
+ // effect at the next loop. A caller override or `/effort` prefix stays
2923
+ // fixed for this query and still wins over live Session config.
2924
+ const configuredEffort = normalizeEffort(requestConfig.modelEffort);
2925
+ const requestUserEffort = userEffort || configuredEffort || null;
2926
+ let resolvedEffort = pickEffort({ scenario, toolLoopTurns, userEffort: requestUserEffort });
2893
2927
 
2894
2928
  // DESIGN.md §9.16: thinking-mode precedence chain. When a VP
2895
2929
  // persona is active, the router/continuity bookkeeping has more
@@ -2911,7 +2945,7 @@ export class Engine {
2911
2945
  ? vpPlan.thinking
2912
2946
  : null;
2913
2947
  const resolved = resolveThinking({
2914
- uiOverride: (userEffort === 'max' || userEffort === 'high') ? userEffort : null,
2948
+ uiOverride: (requestUserEffort === 'max' || requestUserEffort === 'high') ? requestUserEffort : null,
2915
2949
  routerPlan: liveRouterThinking,
2916
2950
  priorPlan: priorPlan && priorPlan.thinking ? priorPlan.thinking : null,
2917
2951
  vpDefault: typeof vpPersona.thinking === 'string' ? vpPersona.thinking : null,
@@ -3006,47 +3040,78 @@ export class Engine {
3006
3040
  }
3007
3041
  }
3008
3042
 
3009
- // Do not durably publish a model-only continuation until every
3010
- // pre-request await is complete and the fresh provider request is about
3011
- // to start. Stop at the visible loop boundary must leave no
3012
- // continuation that the provider never received.
3013
- if (signal?.aborted) throw new LLMAbortError();
3014
- if (pendingContinuationForRequest
3015
- && retryLifecycle.pendingContinuation === pendingContinuationForRequest) {
3016
- this.#persistConversationMessage(pendingContinuationForRequest, { sessionId: runtimeSessionId });
3043
+ // Capture only the provider route before the visible boundary. The
3044
+ // returned async iterable must not make a request or write durable
3045
+ // state; both the retry continuation and Work Center EngineTurn remain
3046
+ // uncommitted until the consumer resumes this `turn_start`.
3047
+ //
3048
+ // Engine configuration and AdapterRouter catalog were captured together
3049
+ // at the loop boundary above. Building the stream here and refreshing
3050
+ // while `turn_start` is visible cannot alter this request revision.
3051
+ const hasCaptureStream = typeof requestAdapter.captureStream === 'function';
3052
+ const captureStream = hasCaptureStream
3053
+ ? requestAdapter.captureStream.bind(requestAdapter)
3054
+ : requestAdapter.stream.bind(requestAdapter);
3055
+ let continuationCommitted = false;
3056
+ const commitRetryContinuation = () => {
3057
+ if (continuationCommitted
3058
+ || !pendingContinuationForRequest
3059
+ || retryLifecycle.pendingContinuation !== pendingContinuationForRequest) return;
3060
+ const persisted = this.#persistConversationMessage(pendingContinuationForRequest, {
3061
+ sessionId: runtimeSessionId,
3062
+ });
3063
+ if (persisted) pendingContinuationForRequest._persistedMessageId = persisted.id;
3017
3064
  conversationMessages.push(pendingContinuationForRequest);
3018
3065
  retryLifecycle.pendingContinuation = null;
3019
- }
3020
-
3021
- activeProviderRequest = typeof prepareProviderRequest === 'function'
3022
- ? prepareProviderRequest({
3066
+ continuationCommitted = true;
3067
+ };
3068
+ const commitDispatch = () => {
3069
+ if (!activeProviderRequest && typeof prepareProviderRequest === 'function') {
3070
+ activeProviderRequest = prepareProviderRequest({
3023
3071
  turnNumber,
3024
3072
  entries: appendedBeforeStream,
3025
3073
  system: systemPrompt,
3026
3074
  messages: wireMessages.map(mapDebugMessage),
3027
3075
  model: currentModel,
3028
- }) || null
3029
- : null;
3030
-
3031
- // Snapshot task results carried by this exact request. Request start
3032
- // is not delivery: fetch may remain pending and then be aborted before
3033
- // the provider processes anything. Ack only after a normal stream end
3034
- // that included the provider's terminal stop event.
3035
- const requestAsyncTaskIds = Array.from(this.#pendingAsyncTaskConfirmIds);
3036
- let sawProviderStop = false;
3037
- for await (const event of this.#adapter.stream({
3076
+ }) || null;
3077
+ }
3078
+ startProviderRequest?.(activeProviderRequest);
3079
+ commitRetryContinuation();
3080
+ };
3081
+ const providerStream = captureStream({
3038
3082
  model: currentModel,
3039
3083
  system: systemPrompt,
3040
3084
  messages: wireMessages,
3041
3085
  tools: toolDefs.length > 0 ? toolDefs : undefined,
3042
- maxTokens: this.#config.maxOutputTokens || 16384,
3086
+ maxTokens: requestConfig.maxOutputTokens || 16384,
3043
3087
  effort: resolvedEffort,
3044
- effortSource: userEffort ? 'user' : 'auto',
3088
+ effortSource: requestUserEffort ? 'user' : 'auto',
3045
3089
  signal,
3046
3090
  onRawExchange: captureRawExchange,
3047
3091
  rawExchangeMaxBytes,
3048
- onRequestStart: () => startProviderRequest?.(activeProviderRequest),
3049
- })) {
3092
+ onRequestStart: () => {
3093
+ // Native adapters invoke this immediately before fetch(). A retry
3094
+ // continuation and Work Center EngineTurn become durable only when
3095
+ // their request crosses dispatch, never when turn_start is shown.
3096
+ commitDispatch();
3097
+ },
3098
+ });
3099
+ yield { type: 'turn_start', turnNumber, threadId };
3100
+
3101
+ // Provider iteration begins after the visible boundary. Native adapters
3102
+ // commit in onRequestStart immediately before fetch. A plain legacy
3103
+ // adapter only enters its generator at iteration, so preserve its old
3104
+ // dispatch semantics while keeping captured Router requests inert.
3105
+ if (signal?.aborted) throw new LLMAbortError();
3106
+ if (!hasCaptureStream) commitDispatch();
3107
+
3108
+ // Snapshot task results carried by this exact request. Request start
3109
+ // is not delivery: fetch may remain pending and then be aborted before
3110
+ // the provider processes anything. Ack only after a normal stream end
3111
+ // that included the provider's terminal stop event.
3112
+ const requestAsyncTaskIds = Array.from(this.#pendingAsyncTaskConfirmIds);
3113
+ let sawProviderStop = false;
3114
+ for await (const event of providerStream) {
3050
3115
  // task-325a (abort-stop fix): per-event abort short-circuit.
3051
3116
  // The adapter is expected to throw AbortError when fetch's
3052
3117
  // signal fires, but in practice undici/HTTP-2/proxy layers
@@ -3398,7 +3463,7 @@ export class Engine {
3398
3463
  }
3399
3464
  }
3400
3465
 
3401
- const earlyFallbackModel = this.#config.fallbackModel;
3466
+ const earlyFallbackModel = requestConfig.fallbackModel;
3402
3467
  if (earlyFallbackModel && earlyFallbackModel !== currentModel
3403
3468
  && (earlyIsRateLimit || earlyIsTransient) && canReplayProviderRequest) {
3404
3469
  endAttemptTrace('fallback_retry');
@@ -609,6 +609,21 @@ export class LLMAdapter {
609
609
  throw new Error('stream() must be implemented by subclass');
610
610
  }
611
611
 
612
+ /**
613
+ * Capture a stream request before a caller crosses an async boundary.
614
+ *
615
+ * Implementations with mutable runtime routing should override this to freeze
616
+ * their dispatch table at capture time. The base implementation retains
617
+ * compatibility with legacy adapters by calling stream() without advancing
618
+ * its async iterator.
619
+ *
620
+ * @param {object} params
621
+ * @returns {AsyncGenerator<StreamEvent>}
622
+ */
623
+ captureStream(params) {
624
+ return this.stream(params);
625
+ }
626
+
612
627
  /**
613
628
  * Make a single model call without tools (for side queries like summarization).
614
629
  *
@@ -301,6 +301,22 @@ export class AdapterRouter extends LLMAdapter {
301
301
  }
302
302
  }
303
303
 
304
+ /**
305
+ * Capture the current routing table for one request before it reaches an
306
+ * async boundary. A later config save may replace the live catalog, but an
307
+ * already-created stream must dispatch through this immutable revision.
308
+ *
309
+ * @returns {{providers: object[], modelToProvider: Map<string, {provider: object, entry: {id: string, protocol?: string}}>, authoritativeManagedProviders: Set<string>, adapterCache: Map<string, LLMAdapter>}}
310
+ */
311
+ #captureDispatchSnapshot() {
312
+ return {
313
+ providers: this.#providers,
314
+ modelToProvider: this.#modelToProvider,
315
+ authoritativeManagedProviders: this.#authoritativeManagedProviders,
316
+ adapterCache: this.#adapterCache,
317
+ };
318
+ }
319
+
304
320
  /**
305
321
  * Resolve an explicit provider/model ref against a provider row even when
306
322
  * the local model catalog is stale. The provider name still must exist; the
@@ -309,16 +325,19 @@ export class AdapterRouter extends LLMAdapter {
309
325
  * @param {string} modelRef
310
326
  * @returns {{provider: object, entry: {id: string, protocol?: string}} | null}
311
327
  */
312
- #resolveProviderQualifiedFallback(modelRef) {
328
+ #resolveProviderQualifiedFallback(modelRef, snapshot = null) {
313
329
  const parsed = parseModelRef(modelRef);
314
330
  if (!parsed.providerName || !parsed.modelId) return null;
315
331
 
316
- const candidates = this.#providers.filter(p => p && p.name === parsed.providerName);
332
+ const providers = snapshot?.providers || this.#providers;
333
+ const authoritativeManagedProviders = snapshot?.authoritativeManagedProviders
334
+ || this.#authoritativeManagedProviders;
335
+ const candidates = providers.filter(p => p && p.name === parsed.providerName);
317
336
  if (candidates.length === 0) return null;
318
337
  // An explicit managed catalog is authoritative. A qualified ref is not
319
338
  // permission to resurrect a model that catalog just removed. Legacy rows
320
339
  // with no models still use the bundled fallback catalog above.
321
- if (this.#authoritativeManagedProviders.has(parsed.providerName)) return null;
340
+ if (authoritativeManagedProviders.has(parsed.providerName)) return null;
322
341
 
323
342
  const inferred = inferProtocolFromModelId(parsed.modelId);
324
343
  if (!inferred) return null;
@@ -355,12 +374,14 @@ export class AdapterRouter extends LLMAdapter {
355
374
  return null;
356
375
  }
357
376
 
358
- #unknownModelError(modelRef) {
377
+ #unknownModelError(modelRef, snapshot = null) {
359
378
  const parsed = parseModelRef(modelRef);
379
+ const providers = snapshot?.providers || this.#providers;
380
+ const modelToProvider = snapshot?.modelToProvider || this.#modelToProvider;
360
381
  if (parsed.providerName) {
361
382
  const providerModels = [];
362
383
  let sawProvider = false;
363
- for (const provider of this.#providers) {
384
+ for (const provider of providers) {
364
385
  if (!provider || provider.name !== parsed.providerName) continue;
365
386
  sawProvider = true;
366
387
  for (const raw of Array.isArray(provider.models) ? provider.models : []) {
@@ -378,7 +399,7 @@ export class AdapterRouter extends LLMAdapter {
378
399
  }
379
400
  return new Error(
380
401
  `Model "${modelRef}" not found in any provider. ` +
381
- `Available models: ${[...this.#modelToProvider.keys()].join(', ') || '(none)'}. ` +
402
+ `Available models: ${[...modelToProvider.keys()].join(', ') || '(none)'}. ` +
382
403
  `Check your config.json providers[].models arrays.`
383
404
  );
384
405
  }
@@ -434,10 +455,11 @@ export class AdapterRouter extends LLMAdapter {
434
455
  * @param {string} modelId
435
456
  * @returns {Promise<{adapter: LLMAdapter, modelId: string}>}
436
457
  */
437
- async #resolveAdapter(modelRef) {
438
- const hit = this.#modelToProvider.get(modelRef) || this.#resolveProviderQualifiedFallback(modelRef);
458
+ async #resolveAdapter(modelRef, snapshot = null) {
459
+ const modelToProvider = snapshot?.modelToProvider || this.#modelToProvider;
460
+ const hit = modelToProvider.get(modelRef) || this.#resolveProviderQualifiedFallback(modelRef, snapshot);
439
461
  if (!hit) {
440
- throw this.#unknownModelError(modelRef);
462
+ throw this.#unknownModelError(modelRef, snapshot);
441
463
  }
442
464
  const { provider, entry } = hit;
443
465
 
@@ -463,7 +485,8 @@ export class AdapterRouter extends LLMAdapter {
463
485
  : null;
464
486
  const authModeKey = anthropicAuthHeaderMode || 'default';
465
487
  const cacheKey = `${provider.name}::${protocol}::${authModeKey}::${apiKeyFp}`;
466
- const cached = this.#adapterCache.get(cacheKey);
488
+ const adapterCache = snapshot?.adapterCache || this.#adapterCache;
489
+ const cached = adapterCache.get(cacheKey);
467
490
  if (cached) return { adapter: cached, modelId: entry.id, protocol, entry };
468
491
 
469
492
  // Token rotation eviction: when a credential provider hands us a NEW
@@ -473,8 +496,8 @@ export class AdapterRouter extends LLMAdapter {
473
496
  // change fingerprint, so this loop never finds anything to evict for
474
497
  // them — back-compat preserved.
475
498
  const prefix = `${provider.name}::${protocol}::${authModeKey}::`;
476
- for (const key of this.#adapterCache.keys()) {
477
- if (key.startsWith(prefix)) this.#adapterCache.delete(key);
499
+ for (const key of adapterCache.keys()) {
500
+ if (key.startsWith(prefix)) adapterCache.delete(key);
478
501
  }
479
502
 
480
503
  let adapter;
@@ -503,7 +526,7 @@ export class AdapterRouter extends LLMAdapter {
503
526
  );
504
527
  }
505
528
 
506
- this.#adapterCache.set(cacheKey, adapter);
529
+ adapterCache.set(cacheKey, adapter);
507
530
  return { adapter, modelId: entry.id, protocol, entry };
508
531
  }
509
532
 
@@ -563,7 +586,7 @@ export class AdapterRouter extends LLMAdapter {
563
586
  * @param {import('./adapter.js').StreamParams} params
564
587
  * @returns {AsyncGenerator<import('./adapter.js').StreamEvent>}
565
588
  */
566
- async #refreshRejectedCredential(provider, model) {
589
+ async #refreshRejectedCredential(provider, model, adapterCache = this.#adapterCache) {
567
590
  if (!provider?.credentialProvider) return false;
568
591
  try {
569
592
  if (provider.credentialProvider === 'github-copilot' && provider.githubToken) {
@@ -584,7 +607,7 @@ export class AdapterRouter extends LLMAdapter {
584
607
  credentialRefreshable: true,
585
608
  });
586
609
  }
587
- this.#adapterCache.clear();
610
+ adapterCache.clear();
588
611
  return true;
589
612
  }
590
613
 
@@ -595,11 +618,32 @@ export class AdapterRouter extends LLMAdapter {
595
618
  err.credentialRefreshable = Boolean(provider?.credentialProvider);
596
619
  }
597
620
 
598
- async *stream(params) {
621
+ captureRequest() {
622
+ // Engine captures this object in the same synchronous boundary as its
623
+ // config snapshot. The returned closure keeps the matching provider
624
+ // catalog even when request preflight yields before stream construction.
625
+ const dispatchSnapshot = this.#captureDispatchSnapshot();
626
+ return {
627
+ captureStream: params => this.#streamWithSnapshot(params, dispatchSnapshot),
628
+ };
629
+ }
630
+
631
+ captureStream(params) {
632
+ // `async *stream()` does not execute until its first `next()`. Capture the
633
+ // route in this ordinary method so a config save after capture returns
634
+ // cannot replace the catalog before the request actually dispatches.
635
+ return this.captureRequest().captureStream(params);
636
+ }
637
+
638
+ stream(params) {
639
+ return this.captureStream(params);
640
+ }
641
+
642
+ async *#streamWithSnapshot(params, dispatchSnapshot) {
599
643
  let refreshedCredential = false;
600
644
  while (true) {
601
- const resolved = await this.#resolveAdapter(params.model);
602
- const provider = this.getProviderForModel(params.model);
645
+ const resolved = await this.#resolveAdapter(params.model, dispatchSnapshot);
646
+ const provider = this.#getProviderForModel(params.model, dispatchSnapshot);
603
647
  const effortContext = {
604
648
  protocol: resolved.protocol,
605
649
  supportsEffort: resolved.entry?.supportsEffort,
@@ -615,7 +659,7 @@ export class AdapterRouter extends LLMAdapter {
615
659
  } catch (err) {
616
660
  this.#annotateAuthError(err, provider, params.model);
617
661
  if (err?.statusCode !== 401 || refreshedCredential
618
- || !(await this.#refreshRejectedCredential(provider, params.model))) throw err;
662
+ || !(await this.#refreshRejectedCredential(provider, params.model, dispatchSnapshot.adapterCache))) throw err;
619
663
  refreshedCredential = true;
620
664
  }
621
665
  }
@@ -628,10 +672,11 @@ export class AdapterRouter extends LLMAdapter {
628
672
  * @returns {Promise<{ text: string, usage: { inputTokens: number, outputTokens: number } }>}
629
673
  */
630
674
  async call(params) {
675
+ const dispatchSnapshot = this.#captureDispatchSnapshot();
631
676
  let refreshedCredential = false;
632
677
  while (true) {
633
- const resolved = await this.#resolveAdapter(params.model);
634
- const provider = this.getProviderForModel(params.model);
678
+ const resolved = await this.#resolveAdapter(params.model, dispatchSnapshot);
679
+ const provider = this.#getProviderForModel(params.model, dispatchSnapshot);
635
680
  const effortContext = {
636
681
  protocol: resolved.protocol,
637
682
  supportsEffort: resolved.entry?.supportsEffort,
@@ -646,12 +691,27 @@ export class AdapterRouter extends LLMAdapter {
646
691
  } catch (err) {
647
692
  this.#annotateAuthError(err, provider, params.model);
648
693
  if (err?.statusCode !== 401 || refreshedCredential
649
- || !(await this.#refreshRejectedCredential(provider, params.model))) throw err;
694
+ || !(await this.#refreshRejectedCredential(provider, params.model, dispatchSnapshot.adapterCache))) throw err;
650
695
  refreshedCredential = true;
651
696
  }
652
697
  }
653
698
  }
654
699
 
700
+ /**
701
+ * Resolve a provider from either the live routing table or a captured
702
+ * request snapshot. Keeping this lookup private prevents callers from
703
+ * accidentally retaining a mutable catalog reference.
704
+ *
705
+ * @param {string} modelId
706
+ * @param {{modelToProvider?: Map<string, {provider: object, entry: object}>}|null} snapshot
707
+ * @returns {object|null}
708
+ */
709
+ #getProviderForModel(modelId, snapshot = null) {
710
+ const modelToProvider = snapshot?.modelToProvider || this.#modelToProvider;
711
+ const hit = modelToProvider.get(modelId) || this.#resolveProviderQualifiedFallback(modelId, snapshot);
712
+ return hit ? hit.provider : null;
713
+ }
714
+
655
715
  /**
656
716
  * Get the provider config for a given model.
657
717
  *
@@ -659,8 +719,7 @@ export class AdapterRouter extends LLMAdapter {
659
719
  * @returns {object|null} — Provider config or null
660
720
  */
661
721
  getProviderForModel(modelId) {
662
- const hit = this.#modelToProvider.get(modelId) || this.#resolveProviderQualifiedFallback(modelId);
663
- return hit ? hit.provider : null;
722
+ return this.#getProviderForModel(modelId);
664
723
  }
665
724
 
666
725
  /**
@@ -90,10 +90,36 @@ export class UsageAccountingAdapter extends LLMAdapter {
90
90
  }
91
91
  }
92
92
 
93
- async *stream(params) {
93
+ captureRequest() {
94
+ const captured = typeof this.#adapter.captureRequest === 'function'
95
+ ? this.#adapter.captureRequest()
96
+ : null;
97
+ return {
98
+ captureStream: params => {
99
+ const requestParams = this.#requestParams(params);
100
+ const capture = captured?.captureStream
101
+ || (typeof this.#adapter.captureStream === 'function'
102
+ ? this.#adapter.captureStream.bind(this.#adapter)
103
+ : this.#adapter.stream.bind(this.#adapter));
104
+ return this.#streamWithAccounting(capture(requestParams));
105
+ },
106
+ };
107
+ }
108
+
109
+ captureStream(params) {
110
+ // Preserve the wrapped adapter's request-capture boundary. In particular,
111
+ // AdapterRouter freezes its provider catalog before the stream is iterated.
112
+ return this.captureRequest().captureStream(params);
113
+ }
114
+
115
+ stream(params) {
116
+ return this.captureStream(params);
117
+ }
118
+
119
+ async *#streamWithAccounting(upstreamStream) {
94
120
  const total = normalizeTokenUsage();
95
121
  try {
96
- for await (const event of this.#adapter.stream(this.#requestParams(params))) {
122
+ for await (const event of upstreamStream) {
97
123
  if (event?.type === 'usage') addUsage(total, event);
98
124
  yield event;
99
125
  }
@@ -256,8 +256,6 @@ export function resolveSessionConfig(userConfig, sessionConfig) {
256
256
  }
257
257
  if (overrides.modelEffort && typeof overrides.modelEffort === 'string' && ALLOWED_EFFORTS.has(overrides.modelEffort)) {
258
258
  base.modelEffort = overrides.modelEffort;
259
- } else {
260
- delete base.modelEffort;
261
259
  }
262
260
  return base;
263
261
  }