@jsonstudio/llms 0.6.1892 → 0.6.2172

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.
Files changed (159) hide show
  1. package/dist/conversion/compat/actions/deepseek-web-request.js +16 -2
  2. package/dist/conversion/compat/actions/deepseek-web-response.d.ts +7 -1
  3. package/dist/conversion/compat/actions/deepseek-web-response.js +302 -40
  4. package/dist/conversion/compat/actions/harvest-tool-calls-from-text.d.ts +5 -0
  5. package/dist/conversion/compat/actions/harvest-tool-calls-from-text.js +7 -4
  6. package/dist/conversion/compat/actions/iflow-tool-text-fallback.d.ts +1 -0
  7. package/dist/conversion/compat/actions/iflow-tool-text-fallback.js +12 -0
  8. package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.js +1 -1
  9. package/dist/conversion/compat/actions/tool-text-request-guidance.d.ts +9 -0
  10. package/dist/conversion/compat/actions/tool-text-request-guidance.js +177 -0
  11. package/dist/conversion/compat/antigravity-session-signature.d.ts +6 -0
  12. package/dist/conversion/compat/antigravity-session-signature.js +15 -0
  13. package/dist/conversion/compat/profiles/chat-deepseek-web.json +52 -1
  14. package/dist/conversion/compat/profiles/chat-glm.json +22 -0
  15. package/dist/conversion/compat/profiles/chat-iflow.json +4 -0
  16. package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +13 -27
  17. package/dist/conversion/hub/operation-table/semantic-mappers/responses-mapper.js +10 -1
  18. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +13 -4
  19. package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.js +1 -53
  20. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +8 -0
  21. package/dist/conversion/hub/pipeline/hub-pipeline.js +8 -4
  22. package/dist/conversion/hub/pipeline/stages/req_inbound/req_inbound_stage3_context_capture/index.js +191 -9
  23. package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +118 -15
  24. package/dist/conversion/hub/pipeline/stages/resp_process/resp_process_stage1_tool_governance/index.js +65 -2
  25. package/dist/conversion/hub/pipeline/stages/resp_process/resp_process_stage3_servertool_orchestration/index.d.ts +34 -0
  26. package/dist/conversion/hub/pipeline/stages/resp_process/resp_process_stage3_servertool_orchestration/index.js +75 -0
  27. package/dist/conversion/hub/process/chat-process.js +85 -18
  28. package/dist/conversion/hub/response/provider-response.js +21 -50
  29. package/dist/conversion/hub/response/response-runtime.js +71 -10
  30. package/dist/conversion/responses/responses-openai-bridge/response-payload.d.ts +3 -0
  31. package/dist/conversion/responses/responses-openai-bridge/response-payload.js +576 -0
  32. package/dist/conversion/responses/responses-openai-bridge/types.d.ts +42 -0
  33. package/dist/conversion/responses/responses-openai-bridge/types.js +1 -0
  34. package/dist/conversion/responses/responses-openai-bridge.d.ts +3 -44
  35. package/dist/conversion/responses/responses-openai-bridge.js +193 -504
  36. package/dist/conversion/shared/anthropic-message-utils.js +82 -2
  37. package/dist/conversion/shared/bridge-message-utils.js +92 -39
  38. package/dist/conversion/shared/snapshot-hooks.js +8 -13
  39. package/dist/conversion/shared/text-markup-normalizer/extractors-apply-patch.d.ts +2 -0
  40. package/dist/conversion/shared/text-markup-normalizer/extractors-apply-patch.js +129 -0
  41. package/dist/conversion/shared/text-markup-normalizer/extractors-json.d.ts +4 -0
  42. package/dist/conversion/shared/text-markup-normalizer/extractors-json.js +637 -0
  43. package/dist/conversion/shared/text-markup-normalizer/extractors-shared.d.ts +21 -0
  44. package/dist/conversion/shared/text-markup-normalizer/extractors-shared.js +177 -0
  45. package/dist/conversion/shared/text-markup-normalizer/extractors-transcript.d.ts +5 -0
  46. package/dist/conversion/shared/text-markup-normalizer/extractors-transcript.js +385 -0
  47. package/dist/conversion/shared/text-markup-normalizer/extractors-xml.d.ts +10 -0
  48. package/dist/conversion/shared/text-markup-normalizer/extractors-xml.js +602 -0
  49. package/dist/conversion/shared/text-markup-normalizer/extractors.d.ts +5 -0
  50. package/dist/conversion/shared/text-markup-normalizer/extractors.js +4 -0
  51. package/dist/conversion/shared/text-markup-normalizer/normalize.d.ts +2 -0
  52. package/dist/conversion/shared/text-markup-normalizer/normalize.js +76 -0
  53. package/dist/conversion/shared/text-markup-normalizer.d.ts +3 -25
  54. package/dist/conversion/shared/text-markup-normalizer.js +2 -1386
  55. package/dist/conversion/shared/tool-governor.js +136 -10
  56. package/dist/filters/utils/snapshot-writer.js +3 -3
  57. package/dist/router/virtual-router/bootstrap/auth-utils.d.ts +6 -0
  58. package/dist/router/virtual-router/bootstrap/auth-utils.js +288 -0
  59. package/dist/router/virtual-router/bootstrap/claude-code-helpers.d.ts +11 -0
  60. package/dist/router/virtual-router/bootstrap/claude-code-helpers.js +18 -0
  61. package/dist/router/virtual-router/bootstrap/config-defaults.d.ts +5 -0
  62. package/dist/router/virtual-router/bootstrap/config-defaults.js +13 -0
  63. package/dist/router/virtual-router/bootstrap/config-normalizers.d.ts +4 -0
  64. package/dist/router/virtual-router/bootstrap/config-normalizers.js +106 -0
  65. package/dist/router/virtual-router/bootstrap/profile-builder.d.ts +7 -0
  66. package/dist/router/virtual-router/bootstrap/profile-builder.js +68 -0
  67. package/dist/router/virtual-router/bootstrap/provider-normalization.d.ts +40 -0
  68. package/dist/router/virtual-router/bootstrap/provider-normalization.js +212 -0
  69. package/dist/router/virtual-router/bootstrap/responses-helpers.d.ts +15 -0
  70. package/dist/router/virtual-router/bootstrap/responses-helpers.js +65 -0
  71. package/dist/router/virtual-router/bootstrap/routing-config.d.ts +23 -0
  72. package/dist/router/virtual-router/bootstrap/routing-config.js +293 -0
  73. package/dist/router/virtual-router/bootstrap/streaming-helpers.d.ts +12 -0
  74. package/dist/router/virtual-router/bootstrap/streaming-helpers.js +128 -0
  75. package/dist/router/virtual-router/bootstrap/utils.d.ts +5 -0
  76. package/dist/router/virtual-router/bootstrap/utils.js +41 -0
  77. package/dist/router/virtual-router/bootstrap/web-search-config.d.ts +4 -0
  78. package/dist/router/virtual-router/bootstrap/web-search-config.js +131 -0
  79. package/dist/router/virtual-router/bootstrap.d.ts +0 -4
  80. package/dist/router/virtual-router/bootstrap.js +31 -1275
  81. package/dist/router/virtual-router/classifier.js +32 -14
  82. package/dist/router/virtual-router/engine/antigravity/alias-lease.js +2 -2
  83. package/dist/router/virtual-router/engine/cooldown-manager.d.ts +34 -0
  84. package/dist/router/virtual-router/engine/cooldown-manager.js +118 -0
  85. package/dist/router/virtual-router/engine/route-analytics.d.ts +28 -0
  86. package/dist/router/virtual-router/engine/route-analytics.js +44 -0
  87. package/dist/router/virtual-router/engine/routing-pools/index.js +165 -4
  88. package/dist/router/virtual-router/engine/sticky-session-manager.d.ts +29 -0
  89. package/dist/router/virtual-router/engine/sticky-session-manager.js +55 -0
  90. package/dist/router/virtual-router/engine-logging.d.ts +42 -1
  91. package/dist/router/virtual-router/engine-logging.js +82 -15
  92. package/dist/router/virtual-router/engine-selection/multimodal-capability.d.ts +3 -0
  93. package/dist/router/virtual-router/engine-selection/multimodal-capability.js +26 -0
  94. package/dist/router/virtual-router/engine-selection/route-utils.js +6 -2
  95. package/dist/router/virtual-router/engine-selection/selection-deps.d.ts +1 -0
  96. package/dist/router/virtual-router/engine-selection/tier-selection.js +31 -1
  97. package/dist/router/virtual-router/engine.d.ts +21 -7
  98. package/dist/router/virtual-router/engine.js +198 -194
  99. package/dist/router/virtual-router/features.js +12 -4
  100. package/dist/router/virtual-router/message-utils.d.ts +8 -0
  101. package/dist/router/virtual-router/message-utils.js +170 -45
  102. package/dist/router/virtual-router/pre-command-file-resolver.js +40 -2
  103. package/dist/router/virtual-router/routing-instructions.d.ts +8 -0
  104. package/dist/router/virtual-router/routing-instructions.js +18 -2
  105. package/dist/router/virtual-router/routing-stop-message-actions.js +34 -10
  106. package/dist/router/virtual-router/routing-stop-message-state-codec.d.ts +2 -0
  107. package/dist/router/virtual-router/routing-stop-message-state-codec.js +50 -1
  108. package/dist/router/virtual-router/stop-message-state-sync.d.ts +1 -1
  109. package/dist/router/virtual-router/stop-message-state-sync.js +3 -0
  110. package/dist/router/virtual-router/token-counter.js +51 -10
  111. package/dist/router/virtual-router/tool-signals.js +4 -0
  112. package/dist/router/virtual-router/types.d.ts +15 -0
  113. package/dist/servertool/clock/session-scope.d.ts +3 -0
  114. package/dist/servertool/clock/session-scope.js +52 -0
  115. package/dist/servertool/clock/state.js +9 -0
  116. package/dist/servertool/clock/tasks.js +12 -1
  117. package/dist/servertool/clock/types.d.ts +3 -0
  118. package/dist/servertool/engine.js +177 -31
  119. package/dist/servertool/handlers/clock-auto.js +2 -8
  120. package/dist/servertool/handlers/clock.js +6 -9
  121. package/dist/servertool/handlers/recursive-detection-guard.js +53 -14
  122. package/dist/servertool/handlers/stop-message-auto/blocked-report.d.ts +16 -0
  123. package/dist/servertool/handlers/stop-message-auto/blocked-report.js +349 -0
  124. package/dist/servertool/handlers/stop-message-auto/iflow-followup.d.ts +23 -0
  125. package/dist/servertool/handlers/stop-message-auto/iflow-followup.js +503 -0
  126. package/dist/servertool/handlers/stop-message-auto/routing-state.d.ts +38 -0
  127. package/dist/servertool/handlers/stop-message-auto/routing-state.js +149 -0
  128. package/dist/servertool/handlers/stop-message-auto/runtime-utils.d.ts +67 -0
  129. package/dist/servertool/handlers/stop-message-auto/runtime-utils.js +387 -0
  130. package/dist/servertool/handlers/stop-message-auto.d.ts +1 -1
  131. package/dist/servertool/handlers/stop-message-auto.js +80 -556
  132. package/dist/servertool/handlers/stop-message-stage-policy/bd-runtime.d.ts +18 -0
  133. package/dist/servertool/handlers/stop-message-stage-policy/bd-runtime.js +398 -0
  134. package/dist/servertool/handlers/stop-message-stage-policy/decision.d.ts +9 -0
  135. package/dist/servertool/handlers/stop-message-stage-policy/decision.js +127 -0
  136. package/dist/servertool/handlers/stop-message-stage-policy/observation.d.ts +2 -0
  137. package/dist/servertool/handlers/stop-message-stage-policy/observation.js +179 -0
  138. package/dist/servertool/handlers/stop-message-stage-policy/templates.d.ts +4 -0
  139. package/dist/servertool/handlers/stop-message-stage-policy/templates.js +96 -0
  140. package/dist/servertool/handlers/stop-message-stage-policy/text-utils.d.ts +9 -0
  141. package/dist/servertool/handlers/stop-message-stage-policy/text-utils.js +89 -0
  142. package/dist/servertool/handlers/stop-message-stage-policy/types.d.ts +59 -0
  143. package/dist/servertool/handlers/stop-message-stage-policy/types.js +1 -0
  144. package/dist/servertool/handlers/stop-message-stage-policy.d.ts +3 -43
  145. package/dist/servertool/handlers/stop-message-stage-policy.js +2 -684
  146. package/dist/servertool/handlers/web-search.js +117 -0
  147. package/dist/servertool/server-side-tools.d.ts +0 -1
  148. package/dist/servertool/server-side-tools.js +4 -3
  149. package/dist/sse/sse-to-json/builders/response-builder.js +16 -0
  150. package/dist/sse/sse-to-json/chat-sse-to-json-converter.d.ts +1 -0
  151. package/dist/sse/sse-to-json/chat-sse-to-json-converter.js +110 -37
  152. package/dist/telemetry/stats-center.d.ts +9 -0
  153. package/dist/telemetry/stats-center.js +29 -1
  154. package/dist/tools/apply-patch/structured/coercion.js +3 -11
  155. package/dist/tools/exec-command/validator.d.ts +1 -0
  156. package/dist/tools/exec-command/validator.js +132 -0
  157. package/dist/tools/tool-registry.d.ts +1 -0
  158. package/dist/tools/tool-registry.js +1 -1
  159. package/package.json +1 -1
@@ -9,15 +9,20 @@ import { getStatsCenter } from '../../telemetry/stats-center.js';
9
9
  import { parseRoutingInstructions, applyRoutingInstructions, cleanMessagesFromRoutingInstructions } from './routing-instructions.js';
10
10
  import { extractMessageText } from './message-utils.js';
11
11
  import { loadRoutingInstructionStateSync, saveRoutingInstructionStateAsync, saveRoutingInstructionStateSync } from './sticky-session-store.js';
12
- import { buildHitReason, formatVirtualRouterHit } from './engine-logging.js';
12
+ import { buildHitReason, createVirtualRouterHitRecord, formatVirtualRouterHit, toVirtualRouterHitEvent } from './engine-logging.js';
13
13
  import { selectDirectProviderModel, selectFromStickyPool, selectProviderImpl } from './engine/routing-pools/index.js';
14
14
  import { applyQuotaDepletedImpl, applyQuotaRecoveryImpl, applySeriesCooldownImpl, applyAntigravityRiskPolicyImpl, handleProviderFailureImpl, mapProviderErrorImpl } from './engine/health/index.js';
15
15
  import { hydrateAntigravityAliasLeaseStoreIfNeeded, recordAntigravitySessionLease, resolveAntigravityAliasReuseCooldownMs } from './engine/antigravity/alias-lease.js';
16
16
  import { buildMetadataInstructions, resolveRoutingMode } from './engine/routing-state/metadata.js';
17
17
  import { getRoutingInstructionState, persistRoutingInstructionState, resolveStopMessageScope } from './engine/routing-state/store.js';
18
+ import { ensureStopMessageModeMaxRepeats } from './routing-stop-message-state-codec.js';
18
19
  import { validateStopMessageStageTemplatesCompleteness } from './stop-message-stage-template-files.js';
19
20
  import { extractKeyAlias, extractKeyIndex, extractProviderId, getProviderModelId } from './engine/provider-key/parse.js';
20
21
  import { resolveSessionScope as resolveSessionScopeImpl, resolveStickyKey as resolveStickyKeyImpl } from './engine/routing-state/keys.js';
22
+ import { RouteAnalytics } from './engine/route-analytics.js';
23
+ import { StickySessionManager } from './engine/sticky-session-manager.js';
24
+ import { CooldownManager } from './engine/cooldown-manager.js';
25
+ const DEFAULT_STOP_MESSAGE_MAX_REPEATS = 10;
21
26
  function normalizeStopMessageStageMode(value) {
22
27
  if (typeof value !== 'string') {
23
28
  return undefined;
@@ -72,9 +77,12 @@ function hasRoutingInstructionMarker(messages) {
72
77
  function hasLatestUserRoutingInstructionMarker(messages) {
73
78
  for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
74
79
  const message = messages[idx];
75
- if (!message || message.role !== 'user') {
80
+ if (!message) {
76
81
  continue;
77
82
  }
83
+ if (message.role !== 'user') {
84
+ return false;
85
+ }
78
86
  const content = extractMessageText(message);
79
87
  if (!content) {
80
88
  return false;
@@ -83,25 +91,30 @@ function hasLatestUserRoutingInstructionMarker(messages) {
83
91
  }
84
92
  return false;
85
93
  }
94
+ function isServerToolFollowupRequest(metadata) {
95
+ if (!metadata || typeof metadata !== 'object') {
96
+ return false;
97
+ }
98
+ const rt = metadata.__rt;
99
+ if (!rt || typeof rt !== 'object' || Array.isArray(rt)) {
100
+ return false;
101
+ }
102
+ const flag = rt.serverToolFollowup;
103
+ return (flag === true ||
104
+ (typeof flag === 'string' && flag.trim().toLowerCase() === 'true'));
105
+ }
86
106
  export class VirtualRouterEngine {
87
107
  routing = {};
88
108
  providerRegistry = new ProviderRegistry();
89
109
  healthManager = new ProviderHealthManager();
90
- providerCooldowns = new Map();
110
+ get providerCooldowns() { return this.cooldownManager.getCooldownMap(); }
91
111
  loadBalancer = new RouteLoadBalancer();
92
112
  classifier = new RoutingClassifier({});
93
113
  contextAdvisor = new ContextAdvisor();
94
114
  contextRouting;
95
- routeStats = new Map();
96
- // Alias selection state (global within this VirtualRouterEngine instance).
97
- // Used by alias-selection strategies to avoid rapid cross-alias switching.
98
- aliasQueueStore = new Map();
99
- // Antigravity alias session lease store:
100
- // - Enforces that one auth alias is not shared across different sessions within a cooldown window.
101
- // - Tracks lastSeenAt per alias runtimeKey (providerId.keyAlias).
102
- antigravityAliasLeaseStore = new Map();
103
- antigravitySessionAliasStore = new Map();
104
- antigravityAliasReuseCooldownMs = 5 * 60_000;
115
+ routeAnalytics = new RouteAnalytics();
116
+ stickySessionManager = new StickySessionManager();
117
+ cooldownManager;
105
118
  antigravityLeasePersistence = { loadedOnce: false, loadedMtimeMs: null, flushTimer: null };
106
119
  debug = console; // thin hook; host may monkey-patch for colored logging
107
120
  healthConfig = null;
@@ -116,7 +129,19 @@ export class VirtualRouterEngine {
116
129
  };
117
130
  routingInstructionState = new Map();
118
131
  quotaView;
132
+ /**
133
+ * Backward-compatible test/debug surface used by existing regression scripts.
134
+ * Keep this as a read-only view over StickySessionManager storage.
135
+ */
136
+ get antigravitySessionAliasStore() {
137
+ return this.stickySessionManager.getAllStores().sessionAliasStore;
138
+ }
119
139
  constructor(deps) {
140
+ this.cooldownManager = new CooldownManager({
141
+ healthStore: deps?.healthStore,
142
+ healthConfig: this.healthConfig,
143
+ quotaView: deps?.quotaView
144
+ });
120
145
  if (deps?.healthStore) {
121
146
  this.healthStore = deps.healthStore;
122
147
  }
@@ -133,6 +158,7 @@ export class VirtualRouterEngine {
133
158
  }
134
159
  if ('healthStore' in deps) {
135
160
  this.healthStore = deps.healthStore ?? undefined;
161
+ this.cooldownManager.updateDeps({ healthStore: this.healthStore });
136
162
  }
137
163
  if ('routingStateStore' in deps) {
138
164
  this.routingStateStore =
@@ -148,15 +174,16 @@ export class VirtualRouterEngine {
148
174
  if ('quotaView' in deps) {
149
175
  const prevQuotaEnabled = Boolean(this.quotaView);
150
176
  this.quotaView = deps.quotaView ?? undefined;
177
+ this.cooldownManager.updateDeps({ quotaView: this.quotaView });
151
178
  const nextQuotaEnabled = Boolean(this.quotaView);
152
179
  // When quotaView is enabled, health/cooldown decisions must be driven by quotaView only.
153
180
  // - Enabling quotaView: clear any legacy router-local cooldown TTLs immediately.
154
181
  // - Disabling quotaView: reload legacy cooldown state from health snapshots.
155
182
  if (!prevQuotaEnabled && nextQuotaEnabled) {
156
- this.providerCooldowns.clear();
183
+ this.cooldownManager.clearAllCooldowns();
157
184
  }
158
185
  else if (prevQuotaEnabled && !nextQuotaEnabled) {
159
- this.providerCooldowns.clear();
186
+ this.cooldownManager.clearAllCooldowns();
160
187
  this.restoreHealthFromStore();
161
188
  }
162
189
  }
@@ -180,6 +207,24 @@ export class VirtualRouterEngine {
180
207
  }
181
208
  return { providerId, modelId };
182
209
  }
210
+ shouldFallbackDirectModelForMedia(direct, features) {
211
+ if (!features.hasImageAttachment) {
212
+ return false;
213
+ }
214
+ const providerId = direct.providerId.trim().toLowerCase();
215
+ const modelId = direct.modelId.trim().toLowerCase();
216
+ if (providerId !== 'qwen') {
217
+ return false;
218
+ }
219
+ const isQwen35Plus = modelId === 'qwen3.5-plus' || modelId === 'qwen3-5-plus' || modelId === 'qwen3_5-plus';
220
+ if (!isQwen35Plus) {
221
+ return false;
222
+ }
223
+ if (!(features.hasVideoAttachment === true && features.hasLocalVideoAttachment === true)) {
224
+ return false;
225
+ }
226
+ return this.routeHasTargets(this.routing.vision);
227
+ }
183
228
  initialize(config) {
184
229
  this.validateConfig(config);
185
230
  this.routing = config.routing;
@@ -187,23 +232,24 @@ export class VirtualRouterEngine {
187
232
  this.healthManager.configure(config.health);
188
233
  this.healthConfig = config.health ?? null;
189
234
  this.healthManager.registerProviders(Object.keys(config.providers));
190
- this.providerCooldowns.clear();
235
+ this.cooldownManager.clearAllCooldowns();
191
236
  this.restoreHealthFromStore();
192
237
  this.loadBalancer = new RouteLoadBalancer(config.loadBalancing);
193
- this.antigravityAliasReuseCooldownMs = resolveAntigravityAliasReuseCooldownMs(config);
238
+ const aliasReuseCooldownMs = resolveAntigravityAliasReuseCooldownMs(config);
239
+ this.stickySessionManager = new StickySessionManager(aliasReuseCooldownMs);
194
240
  hydrateAntigravityAliasLeaseStoreIfNeeded({
195
241
  force: true,
196
- leaseStore: this.antigravityAliasLeaseStore,
242
+ leaseStore: this.stickySessionManager.getAllStores().aliasLeaseStore,
197
243
  persistence: this.antigravityLeasePersistence,
198
- aliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs
244
+ aliasReuseCooldownMs: this.stickySessionManager.getAliasReuseCooldownMs()
199
245
  });
200
246
  this.classifier = new RoutingClassifier(config.classifier);
201
247
  this.contextRouting = config.contextRouting ?? { warnRatio: 0.9, hardLimit: false };
202
248
  this.contextAdvisor.configure(this.contextRouting);
203
249
  this.webSearchForce = config.webSearch?.force === true;
204
- this.routeStats = new Map();
250
+ this.routeAnalytics.getAllRouteStats().clear();
205
251
  for (const routeName of Object.keys(this.routing)) {
206
- this.routeStats.set(routeName, { hits: 0 });
252
+ this.routeAnalytics.getRouteStats(routeName) || this.routeAnalytics.incrementRouteStat(routeName, '', { timestampMs: Date.now(), stopMessage: { active: false } });
207
253
  }
208
254
  }
209
255
  route(request, metadata) {
@@ -231,6 +277,10 @@ export class VirtualRouterEngine {
231
277
  }
232
278
  if (stopMessageScope) {
233
279
  const sessionState = getRoutingInstructionState(stopMessageScope, this.routingInstructionState, this.routingStateStore);
280
+ if (ensureStopMessageModeMaxRepeats(sessionState)) {
281
+ this.routingInstructionState.set(stopMessageScope, sessionState);
282
+ persistRoutingInstructionState(stopMessageScope, sessionState, this.routingStateStore);
283
+ }
234
284
  if (typeof sessionState.stopMessageText === 'string' ||
235
285
  typeof sessionState.stopMessageMaxRepeats === 'number' ||
236
286
  typeof sessionState.stopMessageStageMode === 'string') {
@@ -258,8 +308,16 @@ export class VirtualRouterEngine {
258
308
  }
259
309
  }
260
310
  const parsedInstructions = parseRoutingInstructions(request.messages);
311
+ const serverToolFollowup = isServerToolFollowupRequest(metadata);
261
312
  const latestUserHasMarker = hasLatestUserRoutingInstructionMarker(request.messages);
262
313
  let instructions = parsedInstructions;
314
+ if (serverToolFollowup && instructions.length > 0) {
315
+ instructions = instructions.filter((entry) => entry.type !== 'stopMessageSet' &&
316
+ entry.type !== 'stopMessageMode' &&
317
+ entry.type !== 'stopMessageClear' &&
318
+ entry.type !== 'preCommandSet' &&
319
+ entry.type !== 'preCommandClear');
320
+ }
263
321
  if (stopMessageScope && parsedInstructions.length > 0) {
264
322
  const sessionState = getRoutingInstructionState(stopMessageScope, this.routingInstructionState, this.routingStateStore);
265
323
  const hasStaleStopMessageInstruction = !latestUserHasMarker &&
@@ -278,7 +336,8 @@ export class VirtualRouterEngine {
278
336
  entry.type !== 'stopMessageClear');
279
337
  }
280
338
  }
281
- const hasStopMessageClear = instructions.some((entry) => entry.type === 'stopMessageClear');
339
+ const hasGlobalClear = instructions.some((entry) => entry.type === 'clear');
340
+ const hasStopMessageClear = hasGlobalClear || instructions.some((entry) => entry.type === 'stopMessageClear');
282
341
  const stopMessageSets = instructions.filter((entry) => entry.type === 'stopMessageSet');
283
342
  if (!hasStopMessageClear && stopMessageSets.length > 0) {
284
343
  const sessionText = typeof sessionState.stopMessageText === 'string' ? sessionState.stopMessageText.trim() : '';
@@ -308,8 +367,8 @@ export class VirtualRouterEngine {
308
367
  }
309
368
  // stopMessage must be session-scoped: require explicit sessionId in metadata.
310
369
  // This prevents global/default persistence and ensures the trigger matches the setting sessionId.
311
- if (parsedInstructions.length > 0) {
312
- const hasSessionScopedInstruction = parsedInstructions.some((entry) => entry.type === 'stopMessageSet' ||
370
+ if (instructions.length > 0) {
371
+ const hasSessionScopedInstruction = instructions.some((entry) => entry.type === 'stopMessageSet' ||
313
372
  entry.type === 'stopMessageMode' ||
314
373
  entry.type === 'stopMessageClear' ||
315
374
  entry.type === 'preCommandSet' ||
@@ -318,7 +377,7 @@ export class VirtualRouterEngine {
318
377
  throw new VirtualRouterError('[stopMessage/precommand] requires sessionId (e.g. set x-session-id header or metadata.sessionId).', VirtualRouterErrorCode.CONFIG_ERROR, { requestId: metadata.requestId, entryEndpoint: metadata.entryEndpoint });
319
378
  }
320
379
  }
321
- if (stopMessageScope && parsedInstructions.length > 0) {
380
+ if (stopMessageScope && instructions.length > 0) {
322
381
  const sessionState = getRoutingInstructionState(stopMessageScope, this.routingInstructionState, this.routingStateStore);
323
382
  const nextStopMessageMode = resolveStopMessageStageModeAfterInstructions(instructions, sessionState.stopMessageStageMode);
324
383
  if (nextStopMessageMode === 'on') {
@@ -345,7 +404,8 @@ export class VirtualRouterEngine {
345
404
  if (stopMessageScope) {
346
405
  const hasStopMessageSet = instructions.some((entry) => entry.type === 'stopMessageSet');
347
406
  const hasStopMessageMode = instructions.some((entry) => entry.type === 'stopMessageMode');
348
- const hasStopMessageClear = instructions.some((entry) => entry.type === 'stopMessageClear');
407
+ const hasGlobalClear = instructions.some((entry) => entry.type === 'clear');
408
+ const hasStopMessageClear = hasGlobalClear || instructions.some((entry) => entry.type === 'stopMessageClear');
349
409
  if (hasStopMessageSet || hasStopMessageMode || hasStopMessageClear) {
350
410
  const sessionState = getRoutingInstructionState(stopMessageScope, this.routingInstructionState, this.routingStateStore);
351
411
  let nextSessionState = {
@@ -436,10 +496,27 @@ export class VirtualRouterEngine {
436
496
  nextSessionState.stopMessageStageMode = mode;
437
497
  shouldPersistSessionState = true;
438
498
  }
439
- if (modeMaxRepeats > 0 &&
499
+ const fallbackMaxRepeats = mode === 'on' || mode === 'auto'
500
+ ? DEFAULT_STOP_MESSAGE_MAX_REPEATS
501
+ : 0;
502
+ const normalizedMaxRepeats = modeMaxRepeats > 0 ? modeMaxRepeats : fallbackMaxRepeats;
503
+ if (normalizedMaxRepeats > 0 &&
440
504
  (typeof nextSessionState.stopMessageMaxRepeats !== 'number' ||
441
- Math.floor(nextSessionState.stopMessageMaxRepeats) !== modeMaxRepeats)) {
442
- nextSessionState.stopMessageMaxRepeats = modeMaxRepeats;
505
+ Math.floor(nextSessionState.stopMessageMaxRepeats) !== normalizedMaxRepeats)) {
506
+ nextSessionState.stopMessageMaxRepeats = normalizedMaxRepeats;
507
+ shouldPersistSessionState = true;
508
+ }
509
+ // Explicit mode marker from latest user turn should re-arm lifecycle counters,
510
+ // even when mode/max are unchanged (e.g. <**stopMessage:on,10**>).
511
+ if (mode === 'on' || mode === 'auto') {
512
+ nextSessionState.stopMessageUsed = 0;
513
+ nextSessionState.stopMessageUpdatedAt = Date.now();
514
+ nextSessionState.stopMessageLastUsedAt = undefined;
515
+ nextSessionState.stopMessageStage = undefined;
516
+ nextSessionState.stopMessageSource = 'explicit';
517
+ nextSessionState.stopMessageObservationHash = undefined;
518
+ nextSessionState.stopMessageObservationStableCount = 0;
519
+ nextSessionState.stopMessageBdWorkState = undefined;
443
520
  shouldPersistSessionState = true;
444
521
  }
445
522
  }
@@ -579,6 +656,7 @@ export class VirtualRouterEngine {
579
656
  quotaView: this.quotaView
580
657
  };
581
658
  if (directProviderModel) {
659
+ const forceMediaFallback = this.shouldFallbackDirectModelForMedia(directProviderModel, features);
582
660
  const providerKeys = this.providerRegistry.listProviderKeys(directProviderModel.providerId);
583
661
  let hasModel = false;
584
662
  for (const key of providerKeys) {
@@ -596,19 +674,26 @@ export class VirtualRouterEngine {
596
674
  if (!hasModel) {
597
675
  throw new VirtualRouterError(`Unknown model ${directProviderModel.modelId} for provider ${directProviderModel.providerId}`, VirtualRouterErrorCode.CONFIG_ERROR, { providerId: directProviderModel.providerId, modelId: directProviderModel.modelId });
598
676
  }
599
- classification = {
600
- routeName: 'direct',
601
- confidence: 1,
602
- reasoning: `direct_model:${directProviderModel.providerId}.${directProviderModel.modelId}`,
603
- fallback: false,
604
- candidates: ['direct']
605
- };
606
- requestedRoute = 'direct';
607
- const directSelection = selectDirectProviderModel(directProviderModel.providerId, directProviderModel.modelId, metadata, features, routingState, selectionDeps);
608
- if (!directSelection) {
609
- throw new VirtualRouterError(`All providers unavailable for model ${directProviderModel.providerId}.${directProviderModel.modelId}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { providerId: directProviderModel.providerId, modelId: directProviderModel.modelId });
677
+ if (!forceMediaFallback) {
678
+ const directSelection = selectDirectProviderModel(directProviderModel.providerId, directProviderModel.modelId, metadata, features, routingState, selectionDeps);
679
+ if (!directSelection) {
680
+ throw new VirtualRouterError(`All providers unavailable for model ${directProviderModel.providerId}.${directProviderModel.modelId}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { providerId: directProviderModel.providerId, modelId: directProviderModel.modelId });
681
+ }
682
+ classification = {
683
+ routeName: 'direct',
684
+ confidence: 1,
685
+ reasoning: `direct_model:${directProviderModel.providerId}.${directProviderModel.modelId}`,
686
+ fallback: false,
687
+ candidates: ['direct']
688
+ };
689
+ requestedRoute = 'direct';
690
+ selection = directSelection;
691
+ }
692
+ else {
693
+ classification = this.classifier.classify(features);
694
+ requestedRoute = this.normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
695
+ selection = this.selectProvider(requestedRoute, metadata, classification, features, routingState);
610
696
  }
611
- selection = directSelection;
612
697
  }
613
698
  else {
614
699
  // Prefer target (from "<**!provider.model**>") is evaluated before routing classification.
@@ -763,38 +848,44 @@ export class VirtualRouterEngine {
763
848
  providerKey: selection.providerKey,
764
849
  sessionKey: this.resolveSessionScope(features.metadata),
765
850
  providerRegistry: this.providerRegistry,
766
- leaseStore: this.antigravityAliasLeaseStore,
767
- sessionAliasStore: this.antigravitySessionAliasStore,
851
+ leaseStore: this.stickySessionManager.getAllStores().aliasLeaseStore,
852
+ sessionAliasStore: this.stickySessionManager.getAllStores().sessionAliasStore,
768
853
  persistence: this.antigravityLeasePersistence,
769
- aliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs,
854
+ aliasReuseCooldownMs: this.stickySessionManager.getAliasReuseCooldownMs(),
770
855
  commitSessionBinding: false,
771
856
  debug: this.debug
772
857
  });
773
- this.incrementRouteStat(selection.routeUsed, selection.providerKey);
774
858
  const routingMode = resolveRoutingMode([...metadataInstructions, ...instructions], routingState);
859
+ const hitReason = buildHitReason(selection.routeUsed, selection.providerKey, classification, features, routingMode, { providerRegistry: this.providerRegistry, contextRouting: this.contextRouting });
860
+ const stickyScope = routingMode !== 'none' ? this.resolveSessionScope(metadata) : undefined;
861
+ const routeForLog = routingMode === 'sticky' ? 'sticky' : selection.routeUsed;
862
+ const hitRecord = createVirtualRouterHitRecord({
863
+ routeName: routeForLog,
864
+ poolId: selection.poolId,
865
+ providerKey: selection.providerKey,
866
+ modelId: target.modelId || undefined,
867
+ hitReason,
868
+ stickyScope,
869
+ routingState,
870
+ requestTokens: features.estimatedTokens,
871
+ selectionPenalty: this.resolveSelectionPenalty(selection.providerKey)
872
+ });
873
+ this.routeAnalytics.incrementRouteStat(selection.routeUsed, selection.providerKey, hitRecord);
775
874
  try {
776
- this.statsCenter.recordVirtualRouterHit({
875
+ this.statsCenter.recordVirtualRouterHit(toVirtualRouterHitEvent(hitRecord, {
777
876
  requestId: metadata.requestId,
778
- timestamp: Date.now(),
779
- entryEndpoint: metadata.entryEndpoint || '/v1/chat/completions',
780
- routeName: selection.routeUsed,
781
- pool: selection.poolId || selection.routeUsed,
782
- providerKey: selection.providerKey,
783
- modelId: target.modelId || undefined
784
- });
877
+ entryEndpoint: metadata.entryEndpoint || '/v1/chat/completions'
878
+ }));
785
879
  }
786
880
  catch {
787
881
  // stats must never break routing
788
882
  }
789
- const hitReason = buildHitReason(selection.routeUsed, selection.providerKey, classification, features, routingMode, { providerRegistry: this.providerRegistry, contextRouting: this.contextRouting });
790
- const stickyScope = routingMode !== 'none' ? this.resolveSessionScope(metadata) : undefined;
791
- const routeForLog = routingMode === 'sticky' ? 'sticky' : selection.routeUsed;
792
- const formatted = formatVirtualRouterHit(routeForLog, selection.poolId, selection.providerKey, target.modelId || '', hitReason, stickyScope, routingState);
883
+ const formatted = formatVirtualRouterHit(hitRecord);
793
884
  if (formatted) {
794
885
  this.debug?.log?.(formatted);
795
886
  }
796
887
  else {
797
- this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '', hitReason ? `reason=${hitReason}` : '');
888
+ this.debug?.log?.(formatVirtualRouterHit(hitRecord));
798
889
  }
799
890
  const didFallback = selection.routeUsed !== requestedRoute;
800
891
  return {
@@ -986,10 +1077,10 @@ export class VirtualRouterEngine {
986
1077
  providerKey,
987
1078
  sessionKey: this.resolveSessionScope(metadata),
988
1079
  providerRegistry: this.providerRegistry,
989
- leaseStore: this.antigravityAliasLeaseStore,
990
- sessionAliasStore: this.antigravitySessionAliasStore,
1080
+ leaseStore: this.stickySessionManager.getAllStores().aliasLeaseStore,
1081
+ sessionAliasStore: this.stickySessionManager.getAllStores().sessionAliasStore,
991
1082
  persistence: this.antigravityLeasePersistence,
992
- aliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs,
1083
+ aliasReuseCooldownMs: this.stickySessionManager.getAliasReuseCooldownMs(),
993
1084
  commitSessionBinding: true,
994
1085
  debug: this.debug
995
1086
  });
@@ -998,11 +1089,12 @@ export class VirtualRouterEngine {
998
1089
  getStatus() {
999
1090
  const routes = {};
1000
1091
  for (const [route, pools] of Object.entries(this.routing)) {
1001
- const stats = this.routeStats.get(route) ?? { hits: 0 };
1092
+ const stats = this.routeAnalytics.getRouteStats(route);
1002
1093
  routes[route] = {
1003
1094
  providers: this.flattenPoolTargets(pools),
1004
- hits: stats.hits,
1005
- lastUsedProvider: stats.lastProvider
1095
+ hits: stats?.hits ?? 0,
1096
+ lastUsedProvider: stats?.lastProvider,
1097
+ ...(stats?.lastHit ? { lastHit: { ...stats.lastHit } } : {})
1006
1098
  };
1007
1099
  }
1008
1100
  return {
@@ -1062,22 +1154,30 @@ export class VirtualRouterEngine {
1062
1154
  contextAdvisor: this.contextAdvisor,
1063
1155
  loadBalancer: this.loadBalancer,
1064
1156
  isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
1157
+ getProviderCooldownRemainingMs: (key) => this.getProviderCooldownRemainingMs(key),
1065
1158
  resolveStickyKey: (m) => this.resolveStickyKey(m),
1066
1159
  quotaView: this.quotaView,
1067
- aliasQueueStore: this.aliasQueueStore,
1068
- antigravityAliasLeaseStore: this.antigravityAliasLeaseStore,
1069
- antigravitySessionAliasStore: this.antigravitySessionAliasStore,
1070
- antigravityAliasReuseCooldownMs: this.antigravityAliasReuseCooldownMs
1160
+ aliasQueueStore: this.stickySessionManager.getAllStores().aliasQueueStore,
1161
+ antigravityAliasLeaseStore: this.stickySessionManager.getAllStores().aliasLeaseStore,
1162
+ antigravitySessionAliasStore: this.stickySessionManager.getAllStores().sessionAliasStore,
1163
+ antigravityAliasReuseCooldownMs: this.stickySessionManager.getAliasReuseCooldownMs()
1071
1164
  }, { routingState });
1072
1165
  }
1073
- incrementRouteStat(routeName, providerKey) {
1074
- if (!this.routeStats.has(routeName)) {
1075
- this.routeStats.set(routeName, { hits: 0, lastProvider: providerKey });
1076
- return;
1166
+ resolveSelectionPenalty(providerKey) {
1167
+ if (!this.quotaView) {
1168
+ return undefined;
1169
+ }
1170
+ try {
1171
+ const entry = this.quotaView(providerKey);
1172
+ const raw = entry?.selectionPenalty;
1173
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw <= 0) {
1174
+ return undefined;
1175
+ }
1176
+ return Math.floor(raw);
1177
+ }
1178
+ catch {
1179
+ return undefined;
1077
1180
  }
1078
- const stats = this.routeStats.get(routeName);
1079
- stats.hits += 1;
1080
- stats.lastProvider = providerKey;
1081
1181
  }
1082
1182
  providerHealthConfig() {
1083
1183
  return this.healthManager.getConfig();
@@ -1214,9 +1314,10 @@ export class VirtualRouterEngine {
1214
1314
  contextAdvisor: this.contextAdvisor,
1215
1315
  loadBalancer: this.loadBalancer,
1216
1316
  isProviderCoolingDown: (key) => this.isProviderCoolingDown(key),
1317
+ getProviderCooldownRemainingMs: (key) => this.getProviderCooldownRemainingMs(key),
1217
1318
  resolveStickyKey: (m) => this.resolveStickyKey(m),
1218
1319
  quotaView: this.quotaView,
1219
- aliasQueueStore: this.aliasQueueStore
1320
+ aliasQueueStore: this.stickySessionManager.getAllStores().aliasQueueStore
1220
1321
  }, { routingState: state });
1221
1322
  }
1222
1323
  /**
@@ -1342,22 +1443,16 @@ export class VirtualRouterEngine {
1342
1443
  }
1343
1444
  // mapProviderError/applySeriesCooldown moved to engine-health.ts
1344
1445
  extractExcludedProviderKeySet(metadata) {
1345
- if (!metadata) {
1346
- return new Set();
1347
- }
1348
- const raw = metadata.excludedProviderKeys;
1349
- if (!Array.isArray(raw) || raw.length === 0) {
1350
- return new Set();
1351
- }
1352
- const normalized = raw
1353
- .map((value) => (typeof value === 'string' ? value.trim() : ''))
1354
- .filter((value) => Boolean(value));
1355
- return new Set(normalized);
1446
+ return this.routeAnalytics.extractExcludedProviderKeySet(metadata);
1356
1447
  }
1357
1448
  buildRouteCandidates(requestedRoute, classificationCandidates, features) {
1358
1449
  const forceVision = this.routeHasForceFlag('vision');
1359
1450
  const hasMultimodalTargets = this.routeHasTargets(this.routing.multimodal);
1360
1451
  const hasVisionTargets = this.routeHasTargets(this.routing.vision);
1452
+ const hasLocalVideoAttachment = features.hasVideoAttachment === true && features.hasLocalVideoAttachment === true;
1453
+ if (features.hasImageAttachment && hasLocalVideoAttachment && hasVisionTargets) {
1454
+ return ['vision'];
1455
+ }
1361
1456
  const normalized = this.normalizeRouteAlias(requestedRoute || DEFAULT_ROUTE);
1362
1457
  const baseList = [];
1363
1458
  if (classificationCandidates && classificationCandidates.length) {
@@ -1374,9 +1469,9 @@ export class VirtualRouterEngine {
1374
1469
  baseList.unshift('multimodal');
1375
1470
  }
1376
1471
  }
1377
- else if (hasVisionTargets) {
1472
+ if (hasVisionTargets) {
1378
1473
  if (!baseList.includes('vision')) {
1379
- baseList.unshift('vision');
1474
+ baseList.push('vision');
1380
1475
  }
1381
1476
  }
1382
1477
  if (!forceVision && hasMultimodalTargets) {
@@ -1558,125 +1653,34 @@ export class VirtualRouterEngine {
1558
1653
  return flattened;
1559
1654
  }
1560
1655
  markProviderCooldown(providerKey, cooldownMs) {
1561
- if (!providerKey) {
1562
- return;
1563
- }
1564
- const ttl = typeof cooldownMs === 'number' ? Math.round(cooldownMs) : Number.NaN;
1565
- if (!Number.isFinite(ttl) || ttl <= 0) {
1566
- return;
1567
- }
1568
- this.providerCooldowns.set(providerKey, Date.now() + ttl);
1569
- this.persistHealthSnapshot();
1656
+ this.cooldownManager.markProviderCooldown(providerKey, cooldownMs);
1570
1657
  }
1571
1658
  clearProviderCooldown(providerKey) {
1572
- if (!providerKey) {
1573
- return;
1574
- }
1575
- if (this.providerCooldowns.delete(providerKey)) {
1576
- this.persistHealthSnapshot();
1577
- }
1659
+ this.cooldownManager.clearProviderCooldown(providerKey);
1578
1660
  }
1579
1661
  isProviderCoolingDown(providerKey) {
1662
+ return this.cooldownManager.isProviderCoolingDown(providerKey);
1663
+ }
1664
+ getProviderCooldownRemainingMs(providerKey) {
1580
1665
  if (!providerKey) {
1581
- return false;
1666
+ return 0;
1582
1667
  }
1583
1668
  const expiry = this.providerCooldowns.get(providerKey);
1584
- if (!expiry) {
1585
- return false;
1669
+ if (!expiry || !Number.isFinite(expiry)) {
1670
+ return 0;
1586
1671
  }
1587
- if (Date.now() >= expiry) {
1588
- this.providerCooldowns.delete(providerKey);
1589
- return false;
1590
- }
1591
- return true;
1672
+ const remaining = Math.floor(expiry - Date.now());
1673
+ return remaining > 0 ? remaining : 0;
1592
1674
  }
1593
1675
  restoreHealthFromStore() {
1594
- if (!this.healthStore || typeof this.healthStore.loadInitialSnapshot !== 'function') {
1595
- return;
1596
- }
1597
- // When quotaView is enabled, health/cooldown must be driven by quotaView only.
1598
- // Do not restore legacy router-local cooldown TTLs from health snapshots.
1599
- if (this.quotaView) {
1600
- return;
1601
- }
1602
- let snapshot = null;
1603
- try {
1604
- snapshot = this.healthStore.loadInitialSnapshot();
1605
- }
1606
- catch {
1607
- snapshot = null;
1608
- }
1609
- if (!snapshot) {
1610
- return;
1611
- }
1612
- const now = Date.now();
1613
- const providerKeys = new Set();
1614
- for (const pools of Object.values(this.routing)) {
1615
- for (const pool of pools) {
1616
- for (const key of pool.targets) {
1617
- if (typeof key === 'string' && key) {
1618
- providerKeys.add(key);
1619
- }
1620
- }
1621
- }
1622
- }
1623
- const byKey = new Map();
1624
- for (const entry of snapshot.cooldowns || []) {
1625
- if (!entry || !entry.providerKey) {
1626
- continue;
1627
- }
1628
- if (!providerKeys.has(entry.providerKey)) {
1629
- continue;
1630
- }
1631
- if (!Number.isFinite(entry.cooldownExpiresAt) || entry.cooldownExpiresAt <= now) {
1632
- continue;
1633
- }
1634
- byKey.set(entry.providerKey, entry);
1635
- this.providerCooldowns.set(entry.providerKey, entry.cooldownExpiresAt);
1636
- }
1637
- for (const state of snapshot.providers || []) {
1638
- if (!state || !state.providerKey) {
1639
- continue;
1640
- }
1641
- if (!providerKeys.has(state.providerKey)) {
1642
- continue;
1643
- }
1644
- if (state.cooldownExpiresAt && state.cooldownExpiresAt > now) {
1645
- const ttl = state.cooldownExpiresAt - now;
1646
- if (ttl > 0) {
1647
- this.healthManager.tripProvider(state.providerKey, state.reason, ttl);
1648
- if (!byKey.has(state.providerKey)) {
1649
- this.providerCooldowns.set(state.providerKey, state.cooldownExpiresAt);
1650
- }
1651
- }
1652
- }
1653
- }
1676
+ this.cooldownManager.restoreHealthFromStore();
1654
1677
  }
1655
1678
  buildHealthSnapshot() {
1656
1679
  const providers = this.healthManager.getSnapshot();
1657
- const cooldowns = [];
1658
- const now = Date.now();
1659
- for (const [providerKey, expiry] of this.providerCooldowns.entries()) {
1660
- if (!expiry || expiry <= now) {
1661
- continue;
1662
- }
1663
- cooldowns.push({
1664
- providerKey,
1665
- cooldownExpiresAt: expiry
1666
- });
1667
- }
1668
- return { providers, cooldowns };
1680
+ const cooldownSnapshot = this.cooldownManager.buildHealthSnapshot();
1681
+ return { providers, cooldowns: cooldownSnapshot.cooldowns };
1669
1682
  }
1670
1683
  persistHealthSnapshot() {
1671
- if (!this.healthStore || typeof this.healthStore.persistSnapshot !== 'function') {
1672
- return;
1673
- }
1674
- try {
1675
- const snapshot = this.buildHealthSnapshot();
1676
- this.healthStore.persistSnapshot(snapshot);
1677
- }
1678
- catch {
1679
- // 持久化失败不影响路由主流程
1680
- }
1684
+ this.cooldownManager.persistHealthSnapshot();
1681
1685
  }
1682
1686
  }