@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.
- package/connection/message-router.js +3 -3
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +4 -3
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/engine.js +104 -39
- package/yeaft/llm/adapter.js +15 -0
- package/yeaft/llm/router.js +83 -24
- package/yeaft/llm/usage-accounting.js +28 -2
- package/yeaft/sessions/session-config.js +0 -2
- package/yeaft/web-bridge.js +54 -21
- package/yeaft/work-center/runner.js +51 -3
- package/yeaft/work-center/store.js +3 -1
|
Binary file
|
package/package.json
CHANGED
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
|
-
|
|
2087
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
2891
|
-
//
|
|
2892
|
-
|
|
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: (
|
|
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
|
-
//
|
|
3010
|
-
//
|
|
3011
|
-
//
|
|
3012
|
-
//
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
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
|
-
|
|
3022
|
-
|
|
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
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
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:
|
|
3086
|
+
maxTokens: requestConfig.maxOutputTokens || 16384,
|
|
3043
3087
|
effort: resolvedEffort,
|
|
3044
|
-
effortSource:
|
|
3088
|
+
effortSource: requestUserEffort ? 'user' : 'auto',
|
|
3045
3089
|
signal,
|
|
3046
3090
|
onRawExchange: captureRawExchange,
|
|
3047
3091
|
rawExchangeMaxBytes,
|
|
3048
|
-
onRequestStart: () =>
|
|
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 =
|
|
3466
|
+
const earlyFallbackModel = requestConfig.fallbackModel;
|
|
3402
3467
|
if (earlyFallbackModel && earlyFallbackModel !== currentModel
|
|
3403
3468
|
&& (earlyIsRateLimit || earlyIsTransient) && canReplayProviderRequest) {
|
|
3404
3469
|
endAttemptTrace('fallback_retry');
|
package/yeaft/llm/adapter.js
CHANGED
|
@@ -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
|
*
|
package/yeaft/llm/router.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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: ${[...
|
|
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
|
|
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
|
|
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
|
|
477
|
-
if (key.startsWith(prefix))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|