@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
@@ -11,7 +11,6 @@ export class RoutingClassifier {
11
11
  }
12
12
  classify(features) {
13
13
  const lastToolCategory = features.lastAssistantToolCategory;
14
- const webSearchDeclared = features.hasWebSearchToolDeclared === true;
15
14
  const webSearchIntent = detectWebSearchIntent(features.userTextSample);
16
15
  const localToolContinuation = lastToolCategory === 'read' ||
17
16
  lastToolCategory === 'write' ||
@@ -49,15 +48,10 @@ export class RoutingClassifier {
49
48
  },
50
49
  web_search: {
51
50
  // web_search 仅由“当前请求”触发:
52
- // - 显式声明 web_search 工具;或
53
51
  // - 用户输入命中联网搜索意图关键词。
54
- // 不再使用上一轮 websearch 续写,避免连续轮次被粘住。
55
- triggered: !localToolContinuation && (webSearchDeclared || webSearchIntent),
56
- reason: webSearchDeclared && webSearchIntent
57
- ? 'web_search:tool+intent'
58
- : webSearchDeclared
59
- ? 'web_search:tool-declared'
60
- : 'web_search:intent-keyword'
52
+ // 不再使用工具声明或上一轮 websearch 续写来决定路由。
53
+ triggered: !localToolContinuation && webSearchIntent,
54
+ reason: 'web_search:intent-keyword'
61
55
  },
62
56
  search: {
63
57
  // search 路由:仅在上一轮 assistant 使用 search 类工具时继续命中,
@@ -120,13 +114,15 @@ function detectWebSearchIntent(text) {
120
114
  return false;
121
115
  }
122
116
  const normalized = text.toLowerCase();
117
+ if (isNegativeWebSearchContext(normalized, text)) {
118
+ return false;
119
+ }
123
120
  const directKeywords = [
124
121
  'web search',
125
122
  'web_search',
126
123
  'websearch',
127
124
  'search the web',
128
125
  'internet search',
129
- 'search online',
130
126
  '搜索网页',
131
127
  '联网搜索',
132
128
  '上网搜索',
@@ -139,21 +135,43 @@ function detectWebSearchIntent(text) {
139
135
  return true;
140
136
  }
141
137
  const enVerb = ['search', 'find', 'lookup', 'look up', 'google'];
142
- const enNoun = ['web', 'internet', 'online', 'news', 'latest', 'today'];
138
+ const enNoun = ['web', 'internet', 'online', 'google', 'bing'];
143
139
  const hasEnVerb = enVerb.some((keyword) => normalized.includes(keyword));
144
140
  const hasEnNoun = enNoun.some((keyword) => normalized.includes(keyword));
145
141
  if (hasEnVerb && hasEnNoun) {
146
142
  return true;
147
143
  }
148
- const zhVerb = ['搜索', '查找', '查询', ''];
149
- const zhNoun = ['网络', '联网', '网页', '新闻', '资讯', '实时', '最新', '今天'];
144
+ const zhVerb = ['搜索', '查找', '', '上网查', '上网搜', '联网查', '联网搜'];
145
+ const zhNoun = ['网络', '联网', '网页', '网上', '互联网', '谷歌', '百度'];
150
146
  const hasZhVerb = zhVerb.some((keyword) => text.includes(keyword));
151
147
  const hasZhNoun = zhNoun.some((keyword) => text.includes(keyword));
152
- if (text.includes('上网') || (hasZhVerb && hasZhNoun)) {
148
+ if ((text.includes('上网') || text.includes('联网')) && (text.includes('搜') || text.includes('查'))) {
149
+ return true;
150
+ }
151
+ if (hasZhVerb && hasZhNoun) {
153
152
  return true;
154
153
  }
155
154
  return false;
156
155
  }
156
+ function isNegativeWebSearchContext(normalized, originalText) {
157
+ const englishPatterns = [
158
+ /prefer\s+resources?\s+over\s+web[\s_-]?search/u,
159
+ /prefer[\s\S]{0,40}web[\s_-]?search/u,
160
+ /do\s+not[\s\S]{0,20}web[\s_-]?search/u,
161
+ /don't[\s\S]{0,20}web[\s_-]?search/u,
162
+ /without[\s\S]{0,20}web[\s_-]?search/u,
163
+ /cannot[\s\S]{0,20}web[\s_-]?search/u
164
+ ];
165
+ if (englishPatterns.some((pattern) => pattern.test(normalized))) {
166
+ return true;
167
+ }
168
+ const chinesePatterns = [
169
+ /不能.{0,20}(上网|联网|web[_ -]?search|搜索网页)/u,
170
+ /不要.{0,20}(上网|联网|web[_ -]?search|搜索网页)/u,
171
+ /避免.{0,20}(上网|联网|web[_ -]?search|搜索网页)/u
172
+ ];
173
+ return chinesePatterns.some((pattern) => pattern.test(originalText));
174
+ }
157
175
  function normalizeList(source, fallback) {
158
176
  if (!source || source.length === 0) {
159
177
  return fallback;
@@ -233,11 +233,11 @@ export function recordAntigravitySessionLease(options) {
233
233
  return;
234
234
  }
235
235
  // Bind sessionKey → alias runtimeKey so subsequent routing will prefer this alias.
236
- options.sessionAliasStore.set(scopedSessionKey, runtimeKeyBase);
236
+ options.sessionAliasStore.set(scopedSessionKey, runtimeKey);
237
237
  try {
238
238
  options.debug?.log?.('[virtual-router][antigravity-session-binding] commit', {
239
239
  sessionKey: scopedSessionKey,
240
- runtimeKey: runtimeKeyBase,
240
+ runtimeKey,
241
241
  prev: existing?.sessionKey ?? null
242
242
  });
243
243
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Cooldown Manager Module
3
+ *
4
+ * Manages provider cooldown TTLs and health snapshot persistence.
5
+ */
6
+ import type { VirtualRouterHealthSnapshot, VirtualRouterHealthStore, ProviderHealthConfig } from '../types.js';
7
+ export declare class CooldownManager {
8
+ private providerCooldowns;
9
+ private healthStore?;
10
+ private healthConfig;
11
+ private quotaView?;
12
+ constructor(deps?: {
13
+ healthStore?: VirtualRouterHealthStore;
14
+ healthConfig?: ProviderHealthConfig | null;
15
+ quotaView?: (providerKey: string) => {
16
+ selectionPenalty?: number;
17
+ } | undefined;
18
+ });
19
+ updateDeps(deps: {
20
+ healthStore?: VirtualRouterHealthStore | null;
21
+ healthConfig?: ProviderHealthConfig | null;
22
+ quotaView?: (providerKey: string) => {
23
+ selectionPenalty?: number;
24
+ } | undefined | null;
25
+ }): void;
26
+ markProviderCooldown(providerKey: string, cooldownMs: number | undefined): void;
27
+ clearProviderCooldown(providerKey: string): void;
28
+ isProviderCoolingDown(providerKey: string): boolean;
29
+ restoreHealthFromStore(): void;
30
+ buildHealthSnapshot(): VirtualRouterHealthSnapshot;
31
+ persistHealthSnapshot(): void;
32
+ clearAllCooldowns(): void;
33
+ getCooldownMap(): Map<string, number>;
34
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Cooldown Manager Module
3
+ *
4
+ * Manages provider cooldown TTLs and health snapshot persistence.
5
+ */
6
+ export class CooldownManager {
7
+ providerCooldowns = new Map();
8
+ healthStore;
9
+ healthConfig = null;
10
+ quotaView;
11
+ constructor(deps) {
12
+ if (deps?.healthStore)
13
+ this.healthStore = deps.healthStore;
14
+ if (deps?.healthConfig !== undefined)
15
+ this.healthConfig = deps.healthConfig;
16
+ if (deps?.quotaView)
17
+ this.quotaView = deps.quotaView;
18
+ }
19
+ updateDeps(deps) {
20
+ if ('healthStore' in deps)
21
+ this.healthStore = deps.healthStore ?? undefined;
22
+ if ('healthConfig' in deps)
23
+ this.healthConfig = deps.healthConfig ?? null;
24
+ if ('quotaView' in deps) {
25
+ const prevQuotaEnabled = Boolean(this.quotaView);
26
+ this.quotaView = deps.quotaView ?? undefined;
27
+ const nextQuotaEnabled = Boolean(this.quotaView);
28
+ // When quotaView is enabled, cooldown must be driven by quotaView only.
29
+ if (!prevQuotaEnabled && nextQuotaEnabled) {
30
+ this.providerCooldowns.clear();
31
+ }
32
+ else if (prevQuotaEnabled && !nextQuotaEnabled) {
33
+ this.providerCooldowns.clear();
34
+ this.restoreHealthFromStore();
35
+ }
36
+ }
37
+ }
38
+ markProviderCooldown(providerKey, cooldownMs) {
39
+ if (!providerKey)
40
+ return;
41
+ const ttl = typeof cooldownMs === 'number' ? Math.round(cooldownMs) : Number.NaN;
42
+ if (!Number.isFinite(ttl) || ttl <= 0)
43
+ return;
44
+ this.providerCooldowns.set(providerKey, Date.now() + ttl);
45
+ this.persistHealthSnapshot();
46
+ }
47
+ clearProviderCooldown(providerKey) {
48
+ if (!providerKey)
49
+ return;
50
+ if (this.providerCooldowns.delete(providerKey)) {
51
+ this.persistHealthSnapshot();
52
+ }
53
+ }
54
+ isProviderCoolingDown(providerKey) {
55
+ if (!providerKey)
56
+ return false;
57
+ const expiry = this.providerCooldowns.get(providerKey);
58
+ if (!expiry)
59
+ return false;
60
+ if (Date.now() >= expiry) {
61
+ this.providerCooldowns.delete(providerKey);
62
+ return false;
63
+ }
64
+ return true;
65
+ }
66
+ restoreHealthFromStore() {
67
+ if (!this.healthStore || typeof this.healthStore.loadInitialSnapshot !== 'function')
68
+ return;
69
+ // When quotaView is enabled, health/cooldown must be driven by quotaView only.
70
+ if (this.quotaView)
71
+ return;
72
+ let snapshot = null;
73
+ try {
74
+ snapshot = this.healthStore.loadInitialSnapshot();
75
+ }
76
+ catch {
77
+ snapshot = null;
78
+ }
79
+ if (!snapshot)
80
+ return;
81
+ const now = Date.now();
82
+ const byKey = new Map();
83
+ for (const entry of snapshot.cooldowns || []) {
84
+ if (!entry?.providerKey || !Number.isFinite(entry.cooldownExpiresAt) || entry.cooldownExpiresAt <= now)
85
+ continue;
86
+ byKey.set(entry.providerKey, entry);
87
+ this.providerCooldowns.set(entry.providerKey, entry.cooldownExpiresAt);
88
+ }
89
+ // Note: Provider health manager's state is separate; we only restore local cooldowns.
90
+ }
91
+ buildHealthSnapshot() {
92
+ const cooldowns = [];
93
+ const now = Date.now();
94
+ for (const [providerKey, expiry] of this.providerCooldowns.entries()) {
95
+ if (!expiry || expiry <= now)
96
+ continue;
97
+ cooldowns.push({ providerKey, cooldownExpiresAt: expiry });
98
+ }
99
+ return { providers: [], cooldowns }; // providers part handled by health manager
100
+ }
101
+ persistHealthSnapshot() {
102
+ if (!this.healthStore || typeof this.healthStore.persistSnapshot !== 'function')
103
+ return;
104
+ try {
105
+ const snapshot = this.buildHealthSnapshot();
106
+ this.healthStore.persistSnapshot(snapshot);
107
+ }
108
+ catch {
109
+ // persistence failure does not affect routing
110
+ }
111
+ }
112
+ clearAllCooldowns() {
113
+ this.providerCooldowns.clear();
114
+ }
115
+ getCooldownMap() {
116
+ return this.providerCooldowns;
117
+ }
118
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Route Analytics Module
3
+ *
4
+ * Route statistics and hit tracking extracted from VirtualRouterEngine.
5
+ */
6
+ import type { RouterMetadataInput } from '../types.js';
7
+ import type { VirtualRouterHitRecord } from '../engine-logging.js';
8
+ export interface RouteLastHit {
9
+ timestampMs: number;
10
+ reason?: string;
11
+ requestTokens?: number;
12
+ selectionPenalty?: number;
13
+ stopMessageActive: boolean;
14
+ stopMessageMode?: 'on' | 'off' | 'auto';
15
+ stopMessageRemaining?: number;
16
+ }
17
+ export interface RouteStats {
18
+ hits: number;
19
+ lastProvider: string;
20
+ lastHit: RouteLastHit;
21
+ }
22
+ export declare class RouteAnalytics {
23
+ private routeStats;
24
+ incrementRouteStat(routeName: string, providerKey: string, hitRecord: VirtualRouterHitRecord): void;
25
+ getRouteStats(routeName: string): RouteStats | undefined;
26
+ getAllRouteStats(): Map<string, RouteStats>;
27
+ extractExcludedProviderKeySet(metadata: RouterMetadataInput | undefined): Set<string>;
28
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Route Analytics Module
3
+ *
4
+ * Route statistics and hit tracking extracted from VirtualRouterEngine.
5
+ */
6
+ export class RouteAnalytics {
7
+ routeStats = new Map();
8
+ incrementRouteStat(routeName, providerKey, hitRecord) {
9
+ const nextLastHit = {
10
+ timestampMs: hitRecord.timestampMs,
11
+ ...(hitRecord.hitReason ? { reason: hitRecord.hitReason } : {}),
12
+ ...(typeof hitRecord.requestTokens === 'number' ? { requestTokens: hitRecord.requestTokens } : {}),
13
+ ...(typeof hitRecord.selectionPenalty === 'number' ? { selectionPenalty: hitRecord.selectionPenalty } : {}),
14
+ stopMessageActive: hitRecord.stopMessage.active,
15
+ ...(hitRecord.stopMessage.mode !== 'unset' ? { stopMessageMode: hitRecord.stopMessage.mode } : {}),
16
+ ...(hitRecord.stopMessage.remaining >= 0 ? { stopMessageRemaining: hitRecord.stopMessage.remaining } : {})
17
+ };
18
+ if (!this.routeStats.has(routeName)) {
19
+ this.routeStats.set(routeName, { hits: 1, lastProvider: providerKey, lastHit: nextLastHit });
20
+ return;
21
+ }
22
+ const stats = this.routeStats.get(routeName);
23
+ stats.hits += 1;
24
+ stats.lastProvider = providerKey;
25
+ stats.lastHit = nextLastHit;
26
+ }
27
+ getRouteStats(routeName) {
28
+ return this.routeStats.get(routeName);
29
+ }
30
+ getAllRouteStats() {
31
+ return this.routeStats;
32
+ }
33
+ extractExcludedProviderKeySet(metadata) {
34
+ if (!metadata)
35
+ return new Set();
36
+ const raw = metadata.excludedProviderKeys;
37
+ if (!Array.isArray(raw) || raw.length === 0)
38
+ return new Set();
39
+ const normalized = raw
40
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
41
+ .filter((value) => Boolean(value));
42
+ return new Set(normalized);
43
+ }
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_ROUTE, VirtualRouterError, VirtualRouterErrorCode } from '../../types.js';
2
- import { extractExcludedProviderKeySet, extractProviderId } from '../../engine-selection/key-parsing.js';
2
+ import { extractExcludedProviderKeySet, extractKeyAlias, extractKeyIndex, extractProviderId, getProviderModelId } from '../../engine-selection/key-parsing.js';
3
+ import { providerSupportsMultimodalRequest } from '../../engine-selection/multimodal-capability.js';
3
4
  import { trySelectFromTier } from '../../engine-selection/tier-selection.js';
4
5
  import { resolveInstructionTarget } from '../../engine-selection/instruction-target.js';
5
6
  import { filterCandidatesByRoutingState } from '../../engine-selection/routing-state-filter.js';
@@ -49,7 +50,8 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
49
50
  stickyResolution = resolveInstructionTarget(state.stickyTarget, deps.providerRegistry);
50
51
  if (stickyResolution && stickyResolution.mode === 'exact') {
51
52
  const stickyKey = stickyResolution.keys[0];
52
- if ((deps.quotaView ? true : deps.healthManager.isAvailable(stickyKey)) &&
53
+ if (stickyProviderMatchesRequestCapabilities(stickyKey, requestedRoute, classification, features, deps.routing, deps.providerRegistry) &&
54
+ (deps.quotaView ? true : deps.healthManager.isAvailable(stickyKey)) &&
53
55
  !excludedProviderKeys.has(stickyKey) &&
54
56
  !deps.isProviderCoolingDown(stickyKey) &&
55
57
  isAllowedByQuota(stickyKey)) {
@@ -62,7 +64,8 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
62
64
  }
63
65
  }
64
66
  if (stickyResolution && stickyResolution.mode === 'filter' && stickyResolution.keys.length > 0) {
65
- const liveKeys = stickyResolution.keys.filter((key) => (deps.quotaView ? true : deps.healthManager.isAvailable(key)) &&
67
+ const liveKeys = stickyResolution.keys.filter((key) => stickyProviderMatchesRequestCapabilities(key, requestedRoute, classification, features, deps.routing, deps.providerRegistry) &&
68
+ (deps.quotaView ? true : deps.healthManager.isAvailable(key)) &&
66
69
  !excludedProviderKeys.has(key) &&
67
70
  !deps.isProviderCoolingDown(key) &&
68
71
  isAllowedByQuota(key));
@@ -175,9 +178,69 @@ export function selectProviderImpl(requestedRoute, metadata, classification, fea
175
178
  allowAliasRotation
176
179
  });
177
180
  }
181
+ function stickyProviderMatchesRequestCapabilities(providerKey, requestedRoute, classification, features, routing, providerRegistry) {
182
+ if (!providerKey) {
183
+ return false;
184
+ }
185
+ if (features.hasImageAttachment) {
186
+ const supportsImageRoute = routeTargetsIncludeProvider(routing, 'multimodal', providerKey) ||
187
+ routeTargetsIncludeProvider(routing, 'vision', providerKey);
188
+ if (!supportsImageRoute) {
189
+ return false;
190
+ }
191
+ if (!providerSupportsMultimodalRequest(providerKey, features, providerRegistry)) {
192
+ return false;
193
+ }
194
+ }
195
+ if (requestRequiresSearchRoute(requestedRoute, classification, features)) {
196
+ const supportsSearchRoute = routeTargetsIncludeProvider(routing, 'web_search', providerKey) ||
197
+ routeTargetsIncludeProvider(routing, 'search', providerKey);
198
+ if (!supportsSearchRoute) {
199
+ return false;
200
+ }
201
+ }
202
+ return true;
203
+ }
204
+ function requestRequiresSearchRoute(requestedRoute, classification, features) {
205
+ const normalizedRequestedRoute = normalizeRouteAlias(requestedRoute || DEFAULT_ROUTE);
206
+ const normalizedClassifiedRoute = normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
207
+ if (normalizedRequestedRoute === 'web_search' || normalizedRequestedRoute === 'search') {
208
+ return true;
209
+ }
210
+ if (normalizedClassifiedRoute === 'web_search' || normalizedClassifiedRoute === 'search') {
211
+ return true;
212
+ }
213
+ const candidates = Array.isArray(classification.candidates) ? classification.candidates : [];
214
+ if (candidates.some((route) => route === 'web_search' || route === 'search')) {
215
+ return true;
216
+ }
217
+ if (features.hasWebSearchToolDeclared === true) {
218
+ return true;
219
+ }
220
+ return features.metadata?.serverToolRequired === true;
221
+ }
222
+ function routeTargetsIncludeProvider(routing, routeName, providerKey) {
223
+ const pools = routing[routeName];
224
+ if (!Array.isArray(pools)) {
225
+ return false;
226
+ }
227
+ for (const pool of pools) {
228
+ if (!Array.isArray(pool.targets)) {
229
+ continue;
230
+ }
231
+ if (pool.targets.includes(providerKey)) {
232
+ return true;
233
+ }
234
+ }
235
+ return false;
236
+ }
237
+ function isFinitePositiveNumber(value) {
238
+ return typeof value === 'number' && Number.isFinite(value) && value > 0;
239
+ }
178
240
  function selectFromCandidates(routes, metadata, classification, features, state, deps, options) {
179
241
  const allowedProviders = new Set(state.allowedProviders);
180
242
  const disabledProviders = new Set(state.disabledProviders);
243
+ const excludedProviderKeys = extractExcludedProviderKeySet(features.metadata);
181
244
  const disabledKeysMap = new Map(Array.from(state.disabledKeys.entries()).map(([provider, keys]) => [
182
245
  provider,
183
246
  new Set(Array.from(keys).map((k) => (typeof k === 'string' ? k : k + 1)))
@@ -187,6 +250,89 @@ function selectFromCandidates(routes, metadata, classification, features, state,
187
250
  const attempted = [];
188
251
  const visitedRoutes = new Set();
189
252
  const routeQueue = initializeRouteQueue(routes);
253
+ const healthSnapshotByProviderKey = new Map(deps.healthManager.getSnapshot().map((entry) => [entry.providerKey, entry]));
254
+ let minRecoverableCooldownMs;
255
+ const recoverableCooldownHints = [];
256
+ const recordRecoverableCooldown = (providerKey, waitMsRaw, source) => {
257
+ const waitMs = Math.max(1, Math.floor(waitMsRaw));
258
+ if (!isFinitePositiveNumber(waitMs)) {
259
+ return;
260
+ }
261
+ if (!Number.isFinite(minRecoverableCooldownMs) || waitMs < minRecoverableCooldownMs) {
262
+ minRecoverableCooldownMs = waitMs;
263
+ }
264
+ const existing = recoverableCooldownHints.find((item) => item.providerKey === providerKey && item.source === source);
265
+ if (!existing) {
266
+ recoverableCooldownHints.push({ providerKey, waitMs, source });
267
+ return;
268
+ }
269
+ if (waitMs < existing.waitMs) {
270
+ existing.waitMs = waitMs;
271
+ }
272
+ };
273
+ const collectRecoverableCooldownForKey = (providerKey) => {
274
+ const nowMs = Date.now();
275
+ if (deps.quotaView) {
276
+ const entry = deps.quotaView(providerKey);
277
+ if (!entry) {
278
+ return;
279
+ }
280
+ if (isFinitePositiveNumber(entry.blacklistUntil) && entry.blacklistUntil > nowMs) {
281
+ return;
282
+ }
283
+ if (isFinitePositiveNumber(entry.cooldownUntil) && entry.cooldownUntil > nowMs) {
284
+ recordRecoverableCooldown(providerKey, entry.cooldownUntil - nowMs, 'quota.cooldown');
285
+ }
286
+ return;
287
+ }
288
+ if (typeof deps.getProviderCooldownRemainingMs === 'function') {
289
+ const localCooldownMs = deps.getProviderCooldownRemainingMs(providerKey);
290
+ if (isFinitePositiveNumber(localCooldownMs)) {
291
+ recordRecoverableCooldown(providerKey, localCooldownMs, 'router.cooldown');
292
+ }
293
+ }
294
+ const healthState = healthSnapshotByProviderKey.get(providerKey);
295
+ if (healthState && isFinitePositiveNumber(healthState.cooldownExpiresAt) && healthState.cooldownExpiresAt > nowMs) {
296
+ recordRecoverableCooldown(providerKey, healthState.cooldownExpiresAt - nowMs, 'health.cooldown');
297
+ }
298
+ };
299
+ const isEligibleTargetForCurrentAttempt = (providerKey) => {
300
+ if (!providerKey || excludedProviderKeys.has(providerKey)) {
301
+ return false;
302
+ }
303
+ if (options.requiredProviderKeys && options.requiredProviderKeys.size > 0 && !options.requiredProviderKeys.has(providerKey)) {
304
+ return false;
305
+ }
306
+ const providerId = extractProviderId(providerKey);
307
+ if (!providerId) {
308
+ return false;
309
+ }
310
+ if (allowedProviders.size > 0 && !allowedProviders.has(providerId)) {
311
+ return false;
312
+ }
313
+ if (disabledProviders.has(providerId)) {
314
+ return false;
315
+ }
316
+ const disabledKeys = disabledKeysMap.get(providerId);
317
+ if (disabledKeys && disabledKeys.size > 0) {
318
+ const keyAlias = extractKeyAlias(providerKey);
319
+ const keyIndex = extractKeyIndex(providerKey);
320
+ if (keyAlias && disabledKeys.has(keyAlias)) {
321
+ return false;
322
+ }
323
+ if (keyIndex !== undefined && disabledKeys.has(keyIndex + 1)) {
324
+ return false;
325
+ }
326
+ }
327
+ const disabledModelSet = disabledModels.get(providerId);
328
+ if (disabledModelSet && disabledModelSet.size > 0) {
329
+ const modelId = getProviderModelId(providerKey, deps.providerRegistry);
330
+ if (modelId && disabledModelSet.has(modelId)) {
331
+ return false;
332
+ }
333
+ }
334
+ return true;
335
+ };
190
336
  const estimatedTokens = typeof features.estimatedTokens === 'number' && Number.isFinite(features.estimatedTokens)
191
337
  ? Math.max(0, features.estimatedTokens)
192
338
  : 0;
@@ -218,8 +364,23 @@ function selectFromCandidates(routes, metadata, classification, features, state,
218
364
  if (failureHint) {
219
365
  attempted.push(failureHint);
220
366
  }
367
+ if (Array.isArray(poolTier.targets) && poolTier.targets.length > 0) {
368
+ for (const providerKey of poolTier.targets) {
369
+ if (!isEligibleTargetForCurrentAttempt(providerKey)) {
370
+ continue;
371
+ }
372
+ collectRecoverableCooldownForKey(providerKey);
373
+ }
374
+ }
221
375
  }
222
376
  }
223
377
  const requestedRoute = normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
224
- throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
378
+ const details = { routeName: requestedRoute, attempted };
379
+ if (isFinitePositiveNumber(minRecoverableCooldownMs)) {
380
+ details.minRecoverableCooldownMs = Math.floor(minRecoverableCooldownMs);
381
+ details.recoverableCooldownHints = recoverableCooldownHints
382
+ .sort((a, b) => a.waitMs - b.waitMs)
383
+ .slice(0, 8);
384
+ }
385
+ throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, details);
225
386
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Sticky Session Manager Module
3
+ *
4
+ * Sticky session and alias lease management extracted from VirtualRouterEngine.
5
+ */
6
+ export interface AliasLease {
7
+ sessionKey: string;
8
+ lastSeenAt: number;
9
+ }
10
+ export declare class StickySessionManager {
11
+ private aliasQueueStore;
12
+ private antigravityAliasLeaseStore;
13
+ private antigravitySessionAliasStore;
14
+ private antigravityAliasReuseCooldownMs;
15
+ constructor(aliasReuseCooldownMs?: number);
16
+ getAliasQueue(alias: string): string[] | undefined;
17
+ setAliasQueue(alias: string, queue: string[]): void;
18
+ getAliasLease(alias: string): AliasLease | undefined;
19
+ setAliasLease(alias: string, lease: AliasLease): void;
20
+ getSessionAlias(sessionKey: string): string | undefined;
21
+ setSessionAlias(sessionKey: string, alias: string): void;
22
+ getAliasReuseCooldownMs(): number;
23
+ hydrateFromStore(store: Map<string, AliasLease>): void;
24
+ getAllStores(): {
25
+ aliasQueueStore: Map<string, string[]>;
26
+ aliasLeaseStore: Map<string, AliasLease>;
27
+ sessionAliasStore: Map<string, string>;
28
+ };
29
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Sticky Session Manager Module
3
+ *
4
+ * Sticky session and alias lease management extracted from VirtualRouterEngine.
5
+ */
6
+ export class StickySessionManager {
7
+ aliasQueueStore = new Map();
8
+ antigravityAliasLeaseStore = new Map();
9
+ antigravitySessionAliasStore = new Map();
10
+ antigravityAliasReuseCooldownMs = 5 * 60_000;
11
+ constructor(aliasReuseCooldownMs) {
12
+ if (typeof aliasReuseCooldownMs === 'number' && aliasReuseCooldownMs > 0) {
13
+ this.antigravityAliasReuseCooldownMs = aliasReuseCooldownMs;
14
+ }
15
+ }
16
+ // Alias queue management
17
+ getAliasQueue(alias) {
18
+ return this.aliasQueueStore.get(alias);
19
+ }
20
+ setAliasQueue(alias, queue) {
21
+ this.aliasQueueStore.set(alias, queue);
22
+ }
23
+ // Antigravity alias lease
24
+ getAliasLease(alias) {
25
+ return this.antigravityAliasLeaseStore.get(alias);
26
+ }
27
+ setAliasLease(alias, lease) {
28
+ this.antigravityAliasLeaseStore.set(alias, lease);
29
+ }
30
+ // Session alias mapping
31
+ getSessionAlias(sessionKey) {
32
+ return this.antigravitySessionAliasStore.get(sessionKey);
33
+ }
34
+ setSessionAlias(sessionKey, alias) {
35
+ this.antigravitySessionAliasStore.set(sessionKey, alias);
36
+ }
37
+ // Cooldown resolution
38
+ getAliasReuseCooldownMs() {
39
+ return this.antigravityAliasReuseCooldownMs;
40
+ }
41
+ // Hydrate from external store (placeholder for future persistence)
42
+ hydrateFromStore(store) {
43
+ for (const [alias, lease] of store.entries()) {
44
+ this.antigravityAliasLeaseStore.set(alias, lease);
45
+ }
46
+ }
47
+ // Get all stores for persistence
48
+ getAllStores() {
49
+ return {
50
+ aliasQueueStore: this.aliasQueueStore,
51
+ aliasLeaseStore: this.antigravityAliasLeaseStore,
52
+ sessionAliasStore: this.antigravitySessionAliasStore
53
+ };
54
+ }
55
+ }