


























@@ -10,6 +10,8 @@ import type { FailoverReason } from "./embedded-agent-helpers.js";
1010import type { FallbackAttempt, ModelCandidate } from "./model-fallback.types.js";
11111212const decisionLog = createSubsystemLogger("model-fallback").child("decision");
13+const AUTH_DECISION_LOG_COALESCE_WINDOW_MS = 30_000;
14+const AUTH_DECISION_LOG_COALESCE_MAX_ENTRIES = 100;
13151416/** Return whether fallback decision logging is enabled for warn-level events. */
1517export function isModelFallbackDecisionLogEnabled(): boolean {
@@ -37,6 +39,18 @@ function buildErrorObservationFields(error?: string): {
3739};
3840}
394142+type ErrorObservationFields = ReturnType<typeof buildErrorObservationFields>;
43+type AuthDecisionLogCoalesceEntry = {
44+lastLoggedAt: number;
45+suppressed: number;
46+};
47+48+const authDecisionLogCoalesceEntries = new Map<string, AuthDecisionLogCoalesceEntry>();
49+50+export function resetModelFallbackDecisionLogCoalescingForTest(): void {
51+authDecisionLogCoalesceEntries.clear();
52+}
53+4054type FallbackStepOutcome = "next_fallback" | "succeeded" | "chain_exhausted";
41554256/** Structured fields that describe one fallback-chain transition. */
@@ -82,6 +96,104 @@ function formatModelRef(candidate: ModelCandidate): string {
8296return `${candidate.provider}/${candidate.model}`;
8397}
849899+function isAuthDecisionLogCoalescingEligible(params: ModelFallbackDecisionParams): boolean {
100+return (
101+(params.decision === "candidate_failed" || params.decision === "skip_candidate") &&
102+(params.reason === "auth" || params.reason === "auth_permanent")
103+);
104+}
105+106+function buildAuthDecisionLogCoalesceKey(
107+params: ModelFallbackDecisionParams,
108+observedError: ErrorObservationFields,
109+): string {
110+return JSON.stringify([
111+params.sessionId ?? params.runId,
112+params.lane,
113+params.requestedProvider,
114+params.requestedModel,
115+params.decision,
116+params.candidate.provider,
117+params.candidate.model,
118+params.attempt,
119+params.total,
120+params.reason,
121+params.status,
122+params.code,
123+observedError.httpCode,
124+observedError.providerErrorType,
125+observedError.errorFingerprint ?? observedError.errorHash,
126+params.nextCandidate ? formatModelRef(params.nextCandidate) : null,
127+params.isPrimary,
128+params.requestedModelMatched,
129+params.fallbackConfigured,
130+]);
131+}
132+133+function pruneAuthDecisionLogCoalesceEntries(now: number): void {
134+const staleBefore = now - AUTH_DECISION_LOG_COALESCE_WINDOW_MS * 2;
135+for (const [key, entry] of authDecisionLogCoalesceEntries) {
136+if (entry.lastLoggedAt < staleBefore) {
137+authDecisionLogCoalesceEntries.delete(key);
138+}
139+}
140+}
141+142+function evictOldestAuthDecisionLogCoalesceEntry(): void {
143+let oldestKey: string | undefined;
144+let oldestLoggedAt = Infinity;
145+for (const [key, entry] of authDecisionLogCoalesceEntries) {
146+if (entry.lastLoggedAt < oldestLoggedAt) {
147+oldestLoggedAt = entry.lastLoggedAt;
148+oldestKey = key;
149+}
150+}
151+if (oldestKey !== undefined) {
152+authDecisionLogCoalesceEntries.delete(oldestKey);
153+}
154+}
155+156+function rememberAuthDecisionLogCoalesceEntry(key: string, now: number): void {
157+if (!authDecisionLogCoalesceEntries.has(key)) {
158+pruneAuthDecisionLogCoalesceEntries(now);
159+if (authDecisionLogCoalesceEntries.size >= AUTH_DECISION_LOG_COALESCE_MAX_ENTRIES) {
160+evictOldestAuthDecisionLogCoalesceEntry();
161+}
162+}
163+authDecisionLogCoalesceEntries.set(key, { lastLoggedAt: now, suppressed: 0 });
164+}
165+166+function resolveAuthDecisionLogCoalescing(
167+params: ModelFallbackDecisionParams,
168+observedError: ErrorObservationFields,
169+): { shouldLog: boolean; suppressedDuplicateCount?: number } {
170+if (!isAuthDecisionLogCoalescingEligible(params)) {
171+return { shouldLog: true };
172+}
173+174+const now = Date.now();
175+const key = buildAuthDecisionLogCoalesceKey(params, observedError);
176+const recent = authDecisionLogCoalesceEntries.get(key);
177+const recentAgeMs = recent ? now - recent.lastLoggedAt : undefined;
178+if (
179+recent &&
180+recentAgeMs !== undefined &&
181+recentAgeMs >= AUTH_DECISION_LOG_COALESCE_WINDOW_MS * 2
182+) {
183+authDecisionLogCoalesceEntries.delete(key);
184+rememberAuthDecisionLogCoalesceEntry(key, now);
185+return { shouldLog: true };
186+}
187+if (recent && recentAgeMs !== undefined && recentAgeMs < AUTH_DECISION_LOG_COALESCE_WINDOW_MS) {
188+recent.suppressed += 1;
189+return { shouldLog: false };
190+}
191+192+const suppressedDuplicateCount = recent?.suppressed;
193+rememberAuthDecisionLogCoalesceEntry(key, now);
194+return { shouldLog: true, suppressedDuplicateCount };
195+}
196+85197function buildFallbackStepFields(params: {
86198decision: "skip_candidate" | "candidate_failed" | "candidate_succeeded";
87199candidate: ModelCandidate;
@@ -158,6 +270,17 @@ export function logModelFallbackDecision(
158270 ? ` providerErrorType=${sanitizeForLog(observedError.providerErrorType)}`
159271 : "";
160272const detailSuffix = detailText ? ` detail=${sanitizeForLog(detailText)}` : "";
273+const logCoalescing = resolveAuthDecisionLogCoalescing(params, observedError);
274+if (!logCoalescing.shouldLog) {
275+return fallbackStepFields;
276+}
277+const suppressedDuplicateCount = logCoalescing.suppressedDuplicateCount ?? 0;
278+const suppressedSuffix =
279+suppressedDuplicateCount > 0
280+ ? ` (${suppressedDuplicateCount} duplicates suppressed in last ${
281+ AUTH_DECISION_LOG_COALESCE_WINDOW_MS / 1000
282+ }s)`
283+ : "";
161284decisionLog.warn("model fallback decision", {
162285event: "model_fallback_decision",
163286tags: ["error_handling", "model_fallback", params.decision],
@@ -183,6 +306,7 @@ export function logModelFallbackDecision(
183306fallbackConfigured: params.fallbackConfigured,
184307allowTransientCooldownProbe: params.allowTransientCooldownProbe,
185308profileCount: params.profileCount,
309+ ...(suppressedDuplicateCount > 0 ? { suppressedDuplicateCount } : {}),
186310previousAttempts: params.previousAttempts?.map((attempt) => ({
187311provider: attempt.provider,
188312model: attempt.model,
@@ -193,7 +317,7 @@ export function logModelFallbackDecision(
193317})),
194318consoleMessage:
195319`model fallback decision: decision=${params.decision} requested=${sanitizeForLog(params.requestedProvider)}/${sanitizeForLog(params.requestedModel)} ` +
196-`candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText}${providerErrorTypeSuffix} next=${nextText}${detailSuffix}`,
320+`candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText}${providerErrorTypeSuffix} next=${nextText}${detailSuffix}${suppressedSuffix}`,
197321});
198322return fallbackStepFields;
199323}
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。