@jsonstudio/rcc 0.89.333 → 0.89.548

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 (191) hide show
  1. package/dist/build-info.js +3 -3
  2. package/dist/build-info.js.map +1 -1
  3. package/dist/cli.js +110 -1
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/token-daemon.d.ts +2 -0
  6. package/dist/commands/token-daemon.js +183 -0
  7. package/dist/commands/token-daemon.js.map +1 -0
  8. package/dist/index.js +20 -3
  9. package/dist/index.js.map +1 -1
  10. package/dist/modules/llmswitch/bridge.d.ts +1 -1
  11. package/dist/modules/llmswitch/bridge.js +3 -2
  12. package/dist/modules/llmswitch/bridge.js.map +1 -1
  13. package/dist/modules/pipeline/utils/colored-logger.js +3 -1
  14. package/dist/modules/pipeline/utils/colored-logger.js.map +1 -1
  15. package/dist/providers/auth/gemini-cli-userinfo-helper.js +12 -2
  16. package/dist/providers/auth/gemini-cli-userinfo-helper.js.map +1 -1
  17. package/dist/providers/auth/oauth-lifecycle.js +337 -25
  18. package/dist/providers/auth/oauth-lifecycle.js.map +1 -1
  19. package/dist/providers/core/config/oauth-flows.d.ts +23 -0
  20. package/dist/providers/core/config/oauth-flows.js +92 -5
  21. package/dist/providers/core/config/oauth-flows.js.map +1 -1
  22. package/dist/providers/core/config/provider-oauth-configs.js +9 -3
  23. package/dist/providers/core/config/provider-oauth-configs.js.map +1 -1
  24. package/dist/providers/core/config/service-profiles.js +18 -10
  25. package/dist/providers/core/config/service-profiles.js.map +1 -1
  26. package/dist/providers/core/runtime/base-provider.d.ts +2 -0
  27. package/dist/providers/core/runtime/base-provider.js +35 -1
  28. package/dist/providers/core/runtime/base-provider.js.map +1 -1
  29. package/dist/providers/core/runtime/gemini-cli-http-provider.js +87 -20
  30. package/dist/providers/core/runtime/gemini-cli-http-provider.js.map +1 -1
  31. package/dist/providers/core/runtime/http-request-executor.d.ts +1 -0
  32. package/dist/providers/core/runtime/http-request-executor.js +75 -1
  33. package/dist/providers/core/runtime/http-request-executor.js.map +1 -1
  34. package/dist/providers/core/runtime/http-transport-provider.d.ts +2 -0
  35. package/dist/providers/core/runtime/http-transport-provider.js +60 -2
  36. package/dist/providers/core/runtime/http-transport-provider.js.map +1 -1
  37. package/dist/providers/core/runtime/iflow-http-provider.d.ts +4 -0
  38. package/dist/providers/core/runtime/iflow-http-provider.js +28 -0
  39. package/dist/providers/core/runtime/iflow-http-provider.js.map +1 -1
  40. package/dist/providers/core/runtime/rate-limit-manager.d.ts +30 -0
  41. package/dist/providers/core/runtime/rate-limit-manager.js +136 -0
  42. package/dist/providers/core/runtime/rate-limit-manager.js.map +1 -0
  43. package/dist/providers/core/runtime/responses-provider.js +8 -3
  44. package/dist/providers/core/runtime/responses-provider.js.map +1 -1
  45. package/dist/providers/core/runtime/vision-debug-utils.d.ts +13 -0
  46. package/dist/providers/core/runtime/vision-debug-utils.js +114 -0
  47. package/dist/providers/core/runtime/vision-debug-utils.js.map +1 -0
  48. package/dist/providers/core/strategies/oauth-auth-code-flow.js +75 -26
  49. package/dist/providers/core/strategies/oauth-auth-code-flow.js.map +1 -1
  50. package/dist/providers/core/utils/http-client.js +2 -1
  51. package/dist/providers/core/utils/http-client.js.map +1 -1
  52. package/dist/providers/core/utils/provider-error-reporter.js +31 -5
  53. package/dist/providers/core/utils/provider-error-reporter.js.map +1 -1
  54. package/dist/providers/core/utils/provider-type-utils.js +1 -1
  55. package/dist/providers/core/utils/provider-type-utils.js.map +1 -1
  56. package/dist/providers/core/utils/snapshot-writer.d.ts +1 -1
  57. package/dist/providers/core/utils/snapshot-writer.js.map +1 -1
  58. package/dist/server/handlers/sse-dispatcher.js +22 -2
  59. package/dist/server/handlers/sse-dispatcher.js.map +1 -1
  60. package/dist/server/runtime/http-server/index.d.ts +9 -0
  61. package/dist/server/runtime/http-server/index.js +512 -144
  62. package/dist/server/runtime/http-server/index.js.map +1 -1
  63. package/dist/server/runtime/http-server/provider-utils.js +1 -1
  64. package/dist/server/runtime/http-server/provider-utils.js.map +1 -1
  65. package/dist/server/runtime/http-server/request-executor.d.ts +10 -0
  66. package/dist/server/runtime/http-server/request-executor.js +553 -159
  67. package/dist/server/runtime/http-server/request-executor.js.map +1 -1
  68. package/dist/server/runtime/http-server/routes.d.ts +5 -0
  69. package/dist/server/runtime/http-server/routes.js +29 -0
  70. package/dist/server/runtime/http-server/routes.js.map +1 -1
  71. package/dist/server/runtime/http-server/runtime-manager.js +33 -0
  72. package/dist/server/runtime/http-server/runtime-manager.js.map +1 -1
  73. package/dist/server/utils/utf8-chunk-buffer.d.ts +43 -0
  74. package/dist/server/utils/utf8-chunk-buffer.js +132 -0
  75. package/dist/server/utils/utf8-chunk-buffer.js.map +1 -0
  76. package/dist/token-daemon/history-store.d.ts +75 -0
  77. package/dist/token-daemon/history-store.js +207 -0
  78. package/dist/token-daemon/history-store.js.map +1 -0
  79. package/dist/token-daemon/index.d.ts +7 -0
  80. package/dist/token-daemon/index.js +336 -0
  81. package/dist/token-daemon/index.js.map +1 -0
  82. package/dist/token-daemon/server-utils.d.ts +33 -0
  83. package/dist/token-daemon/server-utils.js +155 -0
  84. package/dist/token-daemon/server-utils.js.map +1 -0
  85. package/dist/token-daemon/token-daemon.d.ts +23 -0
  86. package/dist/token-daemon/token-daemon.js +249 -0
  87. package/dist/token-daemon/token-daemon.js.map +1 -0
  88. package/dist/token-daemon/token-types.d.ts +44 -0
  89. package/dist/token-daemon/token-types.js +18 -0
  90. package/dist/token-daemon/token-types.js.map +1 -0
  91. package/dist/token-daemon/token-utils.d.ts +17 -0
  92. package/dist/token-daemon/token-utils.js +153 -0
  93. package/dist/token-daemon/token-utils.js.map +1 -0
  94. package/dist/token-portal/local-token-portal.d.ts +1 -0
  95. package/dist/token-portal/local-token-portal.js +89 -0
  96. package/dist/token-portal/local-token-portal.js.map +1 -0
  97. package/dist/token-portal/render.d.ts +10 -0
  98. package/dist/token-portal/render.js +56 -0
  99. package/dist/token-portal/render.js.map +1 -0
  100. package/dist/tools/semantic-replay.js +7 -6
  101. package/dist/tools/semantic-replay.js.map +1 -1
  102. package/dist/utils/error-handler-registry.d.ts +36 -0
  103. package/dist/utils/error-handler-registry.js +93 -7
  104. package/dist/utils/error-handler-registry.js.map +1 -1
  105. package/node_modules/@jsonstudio/llms/README.md +2 -0
  106. package/node_modules/@jsonstudio/llms/dist/conversion/codecs/gemini-openai-codec.js +137 -5
  107. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/gemini-web-search.d.ts +17 -0
  108. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/gemini-web-search.js +68 -0
  109. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-image-content.d.ts +2 -0
  110. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-image-content.js +83 -0
  111. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-vision-prompt.d.ts +11 -0
  112. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-vision-prompt.js +177 -0
  113. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-web-search.d.ts +2 -0
  114. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/glm-web-search.js +63 -0
  115. package/node_modules/@jsonstudio/llms/dist/conversion/compat/actions/universal-shape-filter.js +11 -0
  116. package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-gemini.json +17 -0
  117. package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-glm.json +190 -181
  118. package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-iflow.json +195 -195
  119. package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-lmstudio.json +43 -43
  120. package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/chat-qwen.json +20 -20
  121. package/node_modules/@jsonstudio/llms/dist/conversion/compat/profiles/responses-c4m.json +42 -42
  122. package/node_modules/@jsonstudio/llms/dist/conversion/config/sample-config.json +1 -1
  123. package/node_modules/@jsonstudio/llms/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +24 -0
  124. package/node_modules/@jsonstudio/llms/dist/conversion/hub/pipeline/compat/compat-types.d.ts +8 -0
  125. package/node_modules/@jsonstudio/llms/dist/conversion/hub/pipeline/hub-pipeline.js +39 -4
  126. package/node_modules/@jsonstudio/llms/dist/conversion/hub/pipeline/target-utils.js +6 -0
  127. package/node_modules/@jsonstudio/llms/dist/conversion/hub/process/chat-process.js +213 -1
  128. package/node_modules/@jsonstudio/llms/dist/conversion/hub/response/provider-response.d.ts +34 -0
  129. package/node_modules/@jsonstudio/llms/dist/conversion/hub/response/provider-response.js +84 -24
  130. package/node_modules/@jsonstudio/llms/dist/conversion/hub/response/server-side-tools.d.ts +26 -0
  131. package/node_modules/@jsonstudio/llms/dist/conversion/hub/response/server-side-tools.js +383 -0
  132. package/node_modules/@jsonstudio/llms/dist/conversion/hub/semantic-mappers/gemini-mapper.js +241 -14
  133. package/node_modules/@jsonstudio/llms/dist/conversion/hub/semantic-mappers/responses-mapper.js +17 -1
  134. package/node_modules/@jsonstudio/llms/dist/conversion/hub/standardized-bridge.js +14 -0
  135. package/node_modules/@jsonstudio/llms/dist/conversion/hub/types/standardized.d.ts +1 -0
  136. package/node_modules/@jsonstudio/llms/dist/conversion/responses/responses-openai-bridge.js +82 -3
  137. package/node_modules/@jsonstudio/llms/dist/conversion/shared/anthropic-message-utils.js +92 -3
  138. package/node_modules/@jsonstudio/llms/dist/conversion/shared/bridge-message-utils.js +137 -10
  139. package/node_modules/@jsonstudio/llms/dist/conversion/shared/responses-output-builder.js +43 -2
  140. package/node_modules/@jsonstudio/llms/dist/conversion/shared/snapshot-utils.js +17 -47
  141. package/node_modules/@jsonstudio/llms/dist/conversion/shared/tool-filter-pipeline.js +1 -0
  142. package/node_modules/@jsonstudio/llms/dist/conversion/shared/tool-mapping.js +25 -2
  143. package/node_modules/@jsonstudio/llms/dist/index.d.ts +1 -0
  144. package/node_modules/@jsonstudio/llms/dist/index.js +1 -0
  145. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/bootstrap.js +308 -43
  146. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/classifier.js +11 -17
  147. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/context-advisor.d.ts +0 -2
  148. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/context-advisor.js +0 -12
  149. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/engine.d.ts +17 -2
  150. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/engine.js +332 -95
  151. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/features.js +1 -1
  152. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/message-utils.js +36 -24
  153. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/provider-registry.js +2 -1
  154. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/token-counter.js +14 -3
  155. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/types.d.ts +66 -2
  156. package/node_modules/@jsonstudio/llms/dist/router/virtual-router/types.js +2 -1
  157. package/node_modules/@jsonstudio/llms/dist/servertool/engine.d.ts +27 -0
  158. package/node_modules/@jsonstudio/llms/dist/servertool/engine.js +60 -0
  159. package/node_modules/@jsonstudio/llms/dist/servertool/flow-types.d.ts +40 -0
  160. package/node_modules/@jsonstudio/llms/dist/servertool/flow-types.js +1 -0
  161. package/node_modules/@jsonstudio/llms/dist/servertool/handlers/vision.d.ts +1 -0
  162. package/node_modules/@jsonstudio/llms/dist/servertool/handlers/vision.js +194 -0
  163. package/node_modules/@jsonstudio/llms/dist/servertool/handlers/web-search.d.ts +1 -0
  164. package/node_modules/@jsonstudio/llms/dist/servertool/handlers/web-search.js +638 -0
  165. package/node_modules/@jsonstudio/llms/dist/servertool/orchestration-types.d.ts +33 -0
  166. package/node_modules/@jsonstudio/llms/dist/servertool/orchestration-types.js +1 -0
  167. package/node_modules/@jsonstudio/llms/dist/servertool/registry.d.ts +18 -0
  168. package/node_modules/@jsonstudio/llms/dist/servertool/registry.js +27 -0
  169. package/node_modules/@jsonstudio/llms/dist/servertool/server-side-tools.d.ts +8 -0
  170. package/node_modules/@jsonstudio/llms/dist/servertool/server-side-tools.js +208 -0
  171. package/node_modules/@jsonstudio/llms/dist/servertool/types.d.ts +88 -0
  172. package/node_modules/@jsonstudio/llms/dist/servertool/types.js +1 -0
  173. package/node_modules/@jsonstudio/llms/dist/servertool/vision-tool.d.ts +2 -0
  174. package/node_modules/@jsonstudio/llms/dist/servertool/vision-tool.js +185 -0
  175. package/node_modules/@jsonstudio/llms/dist/sse/json-to-sse/event-generators/responses.js +15 -3
  176. package/node_modules/@jsonstudio/llms/dist/sse/sse-to-json/builders/response-builder.js +6 -3
  177. package/node_modules/@jsonstudio/llms/dist/sse/sse-to-json/gemini-sse-to-json-converter.js +27 -1
  178. package/node_modules/@jsonstudio/llms/dist/sse/types/gemini-types.d.ts +20 -1
  179. package/node_modules/@jsonstudio/llms/dist/sse/types/responses-types.js +1 -1
  180. package/node_modules/@jsonstudio/llms/dist/telemetry/stats-center.d.ts +73 -0
  181. package/node_modules/@jsonstudio/llms/dist/telemetry/stats-center.js +280 -0
  182. package/node_modules/@jsonstudio/llms/package.json +1 -1
  183. package/package.json +3 -2
  184. package/scripts/pack-mode.mjs +2 -1
  185. package/scripts/publish-rcc.mjs +20 -4
  186. package/scripts/test-iflow-web-search.mjs +141 -0
  187. package/scripts/test-iflow.mjs +93 -1
  188. package/scripts/tests/virtual-router-health.mjs +141 -6
  189. package/dist/tools/replay-request.d.ts +0 -0
  190. package/dist/tools/replay-request.js +0 -2
  191. package/dist/tools/replay-request.js.map +0 -1
@@ -5,6 +5,7 @@ import { RoutingClassifier } from './classifier.js';
5
5
  import { buildRoutingFeatures } from './features.js';
6
6
  import { ContextAdvisor } from './context-advisor.js';
7
7
  import { DEFAULT_MODEL_CONTEXT_TOKENS, DEFAULT_ROUTE, ROUTE_PRIORITY, VirtualRouterError, VirtualRouterErrorCode } from './types.js';
8
+ import { getStatsCenter } from '../../telemetry/stats-center.js';
8
9
  export class VirtualRouterEngine {
9
10
  routing = {};
10
11
  providerRegistry = new ProviderRegistry();
@@ -16,6 +17,9 @@ export class VirtualRouterEngine {
16
17
  routeStats = new Map();
17
18
  debug = console; // thin hook; host may monkey-patch for colored logging
18
19
  healthConfig = null;
20
+ statsCenter = getStatsCenter();
21
+ // Derived flags from VirtualRouterConfig/routing used by process / response layers.
22
+ webSearchForce = false;
19
23
  initialize(config) {
20
24
  this.validateConfig(config);
21
25
  this.routing = config.routing;
@@ -27,6 +31,7 @@ export class VirtualRouterEngine {
27
31
  this.classifier = new RoutingClassifier(config.classifier);
28
32
  this.contextRouting = config.contextRouting ?? { warnRatio: 0.9, hardLimit: false };
29
33
  this.contextAdvisor.configure(this.contextRouting);
34
+ this.webSearchForce = config.webSearch?.force === true;
30
35
  this.routeStats = new Map();
31
36
  for (const routeName of Object.keys(this.routing)) {
32
37
  this.routeStats.set(routeName, { hits: 0 });
@@ -34,27 +39,56 @@ export class VirtualRouterEngine {
34
39
  }
35
40
  route(request, metadata) {
36
41
  const features = buildRoutingFeatures(request, metadata);
37
- const classification = this.classifier.classify(features);
38
- const routeName = classification.routeName || DEFAULT_ROUTE;
39
- const selection = this.selectProvider(routeName, metadata, classification, features);
40
- const target = this.providerRegistry.buildTarget(selection.providerKey);
42
+ const classification = metadata.routeHint && metadata.routeHint.trim()
43
+ ? {
44
+ routeName: metadata.routeHint.trim(),
45
+ confidence: 1,
46
+ reasoning: `route_hint:${metadata.routeHint.trim()}`,
47
+ fallback: false,
48
+ candidates: [metadata.routeHint.trim()]
49
+ }
50
+ : this.classifier.classify(features);
51
+ const requestedRoute = this.normalizeRouteAlias(classification.routeName || DEFAULT_ROUTE);
52
+ const selection = this.selectProvider(requestedRoute, metadata, classification, features);
53
+ const baseTarget = this.providerRegistry.buildTarget(selection.providerKey);
54
+ const forceVision = this.routeHasForceFlag('vision');
55
+ const target = {
56
+ ...baseTarget,
57
+ ...(this.webSearchForce ? { forceWebSearch: true } : {}),
58
+ ...(forceVision ? { forceVision: true } : {})
59
+ };
41
60
  this.healthManager.recordSuccess(selection.providerKey);
42
61
  this.incrementRouteStat(selection.routeUsed, selection.providerKey);
62
+ try {
63
+ this.statsCenter.recordVirtualRouterHit({
64
+ requestId: metadata.requestId,
65
+ timestamp: Date.now(),
66
+ entryEndpoint: metadata.entryEndpoint || '/v1/chat/completions',
67
+ routeName: selection.routeUsed,
68
+ pool: selection.poolId || selection.routeUsed,
69
+ providerKey: selection.providerKey,
70
+ modelId: target.modelId || undefined
71
+ });
72
+ }
73
+ catch {
74
+ // stats must never break routing
75
+ }
43
76
  const hitReason = this.buildHitReason(selection.routeUsed, selection.providerKey, classification, features);
44
- const formatted = this.formatVirtualRouterHit(selection.routeUsed, selection.providerKey, target.modelId || '', hitReason);
77
+ const formatted = this.formatVirtualRouterHit(selection.routeUsed, selection.poolId, selection.providerKey, target.modelId || '', hitReason);
45
78
  if (formatted) {
46
79
  this.debug?.log?.(formatted);
47
80
  }
48
81
  else {
49
82
  this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '', hitReason ? `reason=${hitReason}` : '');
50
83
  }
51
- const didFallback = selection.routeUsed !== routeName || classification.fallback;
84
+ const didFallback = selection.routeUsed !== requestedRoute;
52
85
  return {
53
86
  target,
54
87
  decision: {
55
88
  routeName: selection.routeUsed,
56
89
  providerKey: selection.providerKey,
57
90
  pool: selection.pool,
91
+ poolId: selection.poolId,
58
92
  confidence: classification.confidence,
59
93
  reasoning: classification.reasoning,
60
94
  fallback: didFallback
@@ -63,6 +97,7 @@ export class VirtualRouterEngine {
63
97
  routeName: selection.routeUsed,
64
98
  providerKey: selection.providerKey,
65
99
  pool: selection.pool,
100
+ poolId: selection.poolId,
66
101
  reasoning: classification.reasoning,
67
102
  fallback: didFallback,
68
103
  confidence: classification.confidence
@@ -92,10 +127,10 @@ export class VirtualRouterEngine {
92
127
  }
93
128
  getStatus() {
94
129
  const routes = {};
95
- for (const [route, pool] of Object.entries(this.routing)) {
130
+ for (const [route, pools] of Object.entries(this.routing)) {
96
131
  const stats = this.routeStats.get(route) ?? { hits: 0 };
97
132
  routes[route] = {
98
- providers: [...pool],
133
+ providers: this.flattenPoolTargets(pools),
99
134
  hits: stats.hits,
100
135
  lastUsedProvider: stats.lastProvider
101
136
  };
@@ -105,6 +140,14 @@ export class VirtualRouterEngine {
105
140
  health: this.healthManager.getSnapshot()
106
141
  };
107
142
  }
143
+ /**
144
+ * 将分类器产生的逻辑路由名直接归一化为配置中的路由键。
145
+ * 不再维护 "websearch" 之类的别名,调用方应显式使用 "web_search" 或 "search" 等实际路由名。
146
+ */
147
+ normalizeRouteAlias(routeName) {
148
+ const base = routeName && routeName.trim() ? routeName.trim() : DEFAULT_ROUTE;
149
+ return base;
150
+ }
108
151
  validateConfig(config) {
109
152
  if (!config.routing || typeof config.routing !== 'object') {
110
153
  throw new VirtualRouterError('routing configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
@@ -112,32 +155,39 @@ export class VirtualRouterEngine {
112
155
  if (!config.providers || Object.keys(config.providers).length === 0) {
113
156
  throw new VirtualRouterError('providers configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
114
157
  }
115
- const defaultPool = config.routing[DEFAULT_ROUTE];
116
- if (!Array.isArray(defaultPool) || defaultPool.length === 0) {
158
+ const defaultPools = config.routing[DEFAULT_ROUTE];
159
+ if (!this.routeHasTargets(defaultPools)) {
117
160
  throw new VirtualRouterError('default route must be configured with at least one provider', VirtualRouterErrorCode.CONFIG_ERROR);
118
161
  }
162
+ if (!this.hasPrimaryPool(defaultPools)) {
163
+ throw new VirtualRouterError('default route must define at least one non-backup pool', VirtualRouterErrorCode.CONFIG_ERROR);
164
+ }
119
165
  const providerKeys = new Set(Object.keys(config.providers));
120
- for (const [routeName, pool] of Object.entries(config.routing)) {
121
- if (!Array.isArray(pool) || !pool.length) {
166
+ for (const [routeName, pools] of Object.entries(config.routing)) {
167
+ if (!this.routeHasTargets(pools)) {
122
168
  if (routeName === DEFAULT_ROUTE) {
123
169
  throw new VirtualRouterError('default route cannot be empty', VirtualRouterErrorCode.CONFIG_ERROR);
124
170
  }
125
171
  continue;
126
172
  }
127
- for (const providerKey of pool) {
128
- if (!providerKeys.has(providerKey)) {
129
- throw new VirtualRouterError(`Route ${routeName} references unknown provider ${providerKey}`, VirtualRouterErrorCode.CONFIG_ERROR);
173
+ for (const pool of pools) {
174
+ if (!Array.isArray(pool.targets) || !pool.targets.length) {
175
+ continue;
176
+ }
177
+ for (const providerKey of pool.targets) {
178
+ if (!providerKeys.has(providerKey)) {
179
+ throw new VirtualRouterError(`Route ${routeName} references unknown provider ${providerKey}`, VirtualRouterErrorCode.CONFIG_ERROR);
180
+ }
130
181
  }
131
182
  }
132
183
  }
133
184
  }
134
185
  selectProvider(requestedRoute, metadata, classification, features) {
135
- const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
186
+ const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates, features);
136
187
  const stickyKey = this.resolveStickyKey(metadata);
137
188
  const attempted = [];
138
189
  const visitedRoutes = new Set();
139
- const fallbackRoute = this.resolveFallbackRoute();
140
- const routeQueue = this.initializeRouteQueue(candidates, fallbackRoute);
190
+ const routeQueue = this.initializeRouteQueue(candidates);
141
191
  const estimatedTokens = typeof features.estimatedTokens === 'number' && Number.isFinite(features.estimatedTokens)
142
192
  ? Math.max(0, features.estimatedTokens)
143
193
  : 0;
@@ -146,33 +196,93 @@ export class VirtualRouterEngine {
146
196
  if (visitedRoutes.has(routeName)) {
147
197
  continue;
148
198
  }
149
- const pool = this.routing[routeName];
150
- if (!Array.isArray(pool) || pool.length === 0) {
199
+ const routePools = this.routing[routeName];
200
+ if (!this.routeHasTargets(routePools)) {
151
201
  visitedRoutes.add(routeName);
152
- attempted.push(routeName);
153
- continue;
154
- }
155
- const contextResult = this.contextAdvisor.classify(pool, estimatedTokens, (key) => this.providerRegistry.get(key));
156
- if (this.maybeDeferToFallback(routeName, contextResult, routeQueue, visitedRoutes, fallbackRoute)) {
202
+ attempted.push(`${routeName}:empty`);
157
203
  continue;
158
204
  }
159
205
  visitedRoutes.add(routeName);
160
- const prioritizedPools = this.buildContextCandidatePools(contextResult);
161
- for (const candidatePool of prioritizedPools) {
162
- const providerKey = this.loadBalancer.select({
163
- routeName,
164
- candidates: candidatePool,
165
- stickyKey,
166
- availabilityCheck: (key) => this.healthManager.isAvailable(key)
167
- });
206
+ const orderedPools = this.sortRoutePools(routePools);
207
+ for (const poolTier of orderedPools) {
208
+ const { providerKey, poolTargets, tierId, failureHint } = this.trySelectFromTier(routeName, poolTier, stickyKey, estimatedTokens, features);
168
209
  if (providerKey) {
169
- return { providerKey, routeUsed: routeName, pool };
210
+ return { providerKey, routeUsed: routeName, pool: poolTargets, poolId: tierId };
211
+ }
212
+ if (failureHint) {
213
+ attempted.push(failureHint);
170
214
  }
171
215
  }
172
- attempted.push(this.describeAttempt(routeName, contextResult));
173
216
  }
174
217
  throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
175
218
  }
219
+ trySelectFromTier(routeName, tier, stickyKey, estimatedTokens, features) {
220
+ let targets = Array.isArray(tier.targets) ? tier.targets : [];
221
+ const serverToolRequired = features.metadata?.serverToolRequired === true;
222
+ if (serverToolRequired) {
223
+ const filtered = [];
224
+ for (const key of targets) {
225
+ try {
226
+ const profile = this.providerRegistry.get(key);
227
+ if (!profile.serverToolsDisabled) {
228
+ filtered.push(key);
229
+ }
230
+ }
231
+ catch {
232
+ // ignore unknown providers when filtering for servertools
233
+ }
234
+ }
235
+ targets = filtered;
236
+ }
237
+ // 当当前请求包含图片且路由为 default/thinking 时,优先在该路由池内选择
238
+ // Responses/Gemini 类型的 Provider,以便一次完成多模态推理;如果不存在则回退到原始列表。
239
+ if (features.hasImageAttachment && (routeName === DEFAULT_ROUTE || routeName === 'thinking')) {
240
+ const prioritized = [];
241
+ const fallthrough = [];
242
+ for (const key of targets) {
243
+ try {
244
+ const profile = this.providerRegistry.get(key);
245
+ if (profile.providerType === 'responses') {
246
+ prioritized.push(key);
247
+ }
248
+ else if (profile.providerType === 'gemini') {
249
+ prioritized.push(key);
250
+ }
251
+ else {
252
+ fallthrough.push(key);
253
+ }
254
+ }
255
+ catch {
256
+ fallthrough.push(key);
257
+ }
258
+ }
259
+ if (prioritized.length) {
260
+ targets = prioritized;
261
+ }
262
+ }
263
+ if (!targets.length) {
264
+ return { providerKey: null, poolTargets: [], tierId: tier.id, failureHint: `${routeName}:${tier.id}:empty` };
265
+ }
266
+ const contextResult = this.contextAdvisor.classify(targets, estimatedTokens, (key) => this.providerRegistry.get(key));
267
+ const prioritizedPools = this.buildContextCandidatePools(contextResult);
268
+ for (const candidatePool of prioritizedPools) {
269
+ const providerKey = this.loadBalancer.select({
270
+ routeName: `${routeName}:${tier.id}`,
271
+ candidates: candidatePool,
272
+ stickyKey,
273
+ availabilityCheck: (key) => this.healthManager.isAvailable(key)
274
+ });
275
+ if (providerKey) {
276
+ return { providerKey, poolTargets: tier.targets, tierId: tier.id };
277
+ }
278
+ }
279
+ return {
280
+ providerKey: null,
281
+ poolTargets: tier.targets,
282
+ tierId: tier.id,
283
+ failureHint: this.describeAttempt(routeName, tier.id, contextResult)
284
+ };
285
+ }
176
286
  incrementRouteStat(routeName, providerKey) {
177
287
  if (!this.routeStats.has(routeName)) {
178
288
  this.routeStats.set(routeName, { hits: 0, lastProvider: providerKey });
@@ -185,63 +295,34 @@ export class VirtualRouterEngine {
185
295
  providerHealthConfig() {
186
296
  return this.healthManager.getConfig();
187
297
  }
188
- initializeRouteQueue(candidates, fallbackRoute) {
189
- const queue = Array.from(new Set(candidates));
190
- if (fallbackRoute && !queue.includes(fallbackRoute)) {
191
- queue.push(fallbackRoute);
192
- }
193
- return queue;
194
- }
195
- resolveFallbackRoute() {
196
- const candidate = this.contextRouting?.fallbackRoute;
197
- if (!candidate) {
198
- return undefined;
199
- }
200
- const pool = this.routing[candidate];
201
- if (!Array.isArray(pool) || pool.length === 0) {
202
- return undefined;
203
- }
204
- return candidate;
205
- }
206
- maybeDeferToFallback(routeName, contextResult, queue, visited, fallbackRoute) {
207
- if (!fallbackRoute || fallbackRoute === routeName || visited.has(fallbackRoute)) {
208
- return false;
209
- }
210
- if (!this.contextAdvisor.prefersFallback(contextResult)) {
211
- return false;
212
- }
213
- const fallbackPool = this.routing[fallbackRoute];
214
- if (!Array.isArray(fallbackPool) || fallbackPool.length === 0) {
215
- return false;
216
- }
217
- queue.unshift(routeName);
218
- queue.unshift(fallbackRoute);
219
- return true;
298
+ initializeRouteQueue(candidates) {
299
+ return Array.from(new Set(candidates));
220
300
  }
221
301
  buildContextCandidatePools(result) {
222
302
  const ordered = [];
223
303
  if (result.safe.length) {
224
304
  ordered.push(result.safe);
305
+ // 如果存在安全候选,直接放弃当前处于警戒阈值的模型
306
+ return ordered;
225
307
  }
226
308
  if (result.risky.length) {
227
309
  ordered.push(result.risky);
228
310
  }
229
- if (result.overflow.length && this.contextAdvisor.allowsOverflow()) {
230
- ordered.push(result.overflow);
231
- }
311
+ // ratio >= 1 视为上下文溢出,直接标记为不可用
232
312
  return ordered;
233
313
  }
234
- describeAttempt(routeName, result) {
314
+ describeAttempt(routeName, poolId, result) {
315
+ const prefix = poolId ? `${routeName}:${poolId}` : routeName;
235
316
  if (result.safe.length > 0) {
236
- return `${routeName}:health`;
317
+ return `${prefix}:health`;
237
318
  }
238
319
  if (result.risky.length > 0) {
239
- return `${routeName}:context_risky`;
320
+ return `${prefix}:context_risky`;
240
321
  }
241
322
  if (result.overflow.length > 0) {
242
- return `${routeName}:context_overflow`;
323
+ return `${prefix}:max_context_window`;
243
324
  }
244
- return routeName;
325
+ return prefix;
245
326
  }
246
327
  resolveStickyKey(metadata) {
247
328
  const resume = metadata.responsesResume;
@@ -332,12 +413,32 @@ export class VirtualRouterEngine {
332
413
  return 'client_error';
333
414
  return 'unknown';
334
415
  }
335
- buildRouteCandidates(requestedRoute, classificationCandidates) {
336
- const normalized = requestedRoute || DEFAULT_ROUTE;
337
- const baseList = classificationCandidates && classificationCandidates.length
338
- ? classificationCandidates
339
- : [normalized];
340
- const ordered = this.sortByPriority(baseList);
416
+ buildRouteCandidates(requestedRoute, classificationCandidates, features) {
417
+ const forceVision = this.routeHasForceFlag('vision');
418
+ const normalized = this.normalizeRouteAlias(requestedRoute || DEFAULT_ROUTE);
419
+ const baseList = [];
420
+ if (classificationCandidates && classificationCandidates.length) {
421
+ for (const candidate of classificationCandidates) {
422
+ baseList.push(this.normalizeRouteAlias(candidate));
423
+ }
424
+ }
425
+ else if (normalized) {
426
+ baseList.push(normalized);
427
+ }
428
+ // 当检测到当前请求包含图片时,确保 default/thinking 也参与候选集,
429
+ // 以便优先尝试内建多模态模型(Responses/Gemini),再回落到 vision 路由池。
430
+ if (features.hasImageAttachment && !forceVision) {
431
+ const visionAwareRoutes = [DEFAULT_ROUTE, 'thinking'];
432
+ for (const routeName of visionAwareRoutes) {
433
+ if (this.routeHasTargets(this.routing[routeName]) && !baseList.includes(routeName)) {
434
+ baseList.push(routeName);
435
+ }
436
+ }
437
+ }
438
+ let ordered = this.sortByPriority(baseList);
439
+ if (features.hasImageAttachment && !forceVision) {
440
+ ordered = this.reorderForInlineVision(ordered);
441
+ }
341
442
  const deduped = [];
342
443
  for (const routeName of ordered) {
343
444
  if (routeName && !deduped.includes(routeName)) {
@@ -347,17 +448,59 @@ export class VirtualRouterEngine {
347
448
  if (!deduped.includes(DEFAULT_ROUTE)) {
348
449
  deduped.push(DEFAULT_ROUTE);
349
450
  }
350
- const filtered = deduped.filter((routeName) => {
351
- const pool = this.routing[routeName];
352
- return Array.isArray(pool) && pool.length > 0;
353
- });
354
- if (!filtered.includes(DEFAULT_ROUTE) &&
355
- Array.isArray(this.routing[DEFAULT_ROUTE]) &&
356
- this.routing[DEFAULT_ROUTE].length > 0) {
451
+ const filtered = deduped.filter((routeName) => this.routeHasTargets(this.routing[routeName]));
452
+ if (!filtered.includes(DEFAULT_ROUTE) && this.routeHasTargets(this.routing[DEFAULT_ROUTE])) {
357
453
  filtered.push(DEFAULT_ROUTE);
358
454
  }
359
455
  return filtered.length ? filtered : [DEFAULT_ROUTE];
360
456
  }
457
+ reorderForInlineVision(routeNames) {
458
+ const unique = Array.from(new Set(routeNames.filter(Boolean)));
459
+ if (!unique.length) {
460
+ return unique;
461
+ }
462
+ // 仅当 default/thinking 中存在 Responses/Gemini 提供方时,才将其提前作为「一次完成」优先级。
463
+ const inlinePreferred = [];
464
+ const inlineRoutes = [DEFAULT_ROUTE, 'thinking'];
465
+ for (const routeName of inlineRoutes) {
466
+ if (this.routeSupportsInlineVision(routeName) && !inlinePreferred.includes(routeName)) {
467
+ inlinePreferred.push(routeName);
468
+ }
469
+ }
470
+ if (!inlinePreferred.length) {
471
+ return unique;
472
+ }
473
+ const remaining = [];
474
+ for (const routeName of unique) {
475
+ if (!inlinePreferred.includes(routeName)) {
476
+ remaining.push(routeName);
477
+ }
478
+ }
479
+ return [...inlinePreferred, ...remaining];
480
+ }
481
+ routeSupportsInlineVision(routeName) {
482
+ const pools = this.routing[routeName];
483
+ if (!Array.isArray(pools)) {
484
+ return false;
485
+ }
486
+ for (const pool of pools) {
487
+ if (!Array.isArray(pool.targets)) {
488
+ continue;
489
+ }
490
+ for (const providerKey of pool.targets) {
491
+ try {
492
+ const profile = this.providerRegistry.get(providerKey);
493
+ if (profile.providerType === 'responses' || profile.providerType === 'gemini') {
494
+ return true;
495
+ }
496
+ }
497
+ catch {
498
+ // ignore unknown provider keys during capability probing
499
+ }
500
+ }
501
+ }
502
+ return false;
503
+ }
361
504
  sortByPriority(routeNames) {
362
505
  return [...routeNames].sort((a, b) => this.routeWeight(a) - this.routeWeight(b));
363
506
  }
@@ -365,6 +508,59 @@ export class VirtualRouterEngine {
365
508
  const idx = ROUTE_PRIORITY.indexOf(routeName);
366
509
  return idx >= 0 ? idx : ROUTE_PRIORITY.length;
367
510
  }
511
+ routeHasForceFlag(routeName) {
512
+ const pools = this.routing[routeName];
513
+ if (!Array.isArray(pools)) {
514
+ return false;
515
+ }
516
+ return pools.some((pool) => pool.force);
517
+ }
518
+ routeHasTargets(pools) {
519
+ if (!Array.isArray(pools)) {
520
+ return false;
521
+ }
522
+ return pools.some((pool) => Array.isArray(pool.targets) && pool.targets.length > 0);
523
+ }
524
+ hasPrimaryPool(pools) {
525
+ if (!Array.isArray(pools)) {
526
+ return false;
527
+ }
528
+ return pools.some((pool) => !pool.backup && Array.isArray(pool.targets) && pool.targets.length > 0);
529
+ }
530
+ sortRoutePools(pools) {
531
+ if (!Array.isArray(pools)) {
532
+ return [];
533
+ }
534
+ return pools
535
+ .filter((pool) => Array.isArray(pool.targets) && pool.targets.length > 0)
536
+ .sort((a, b) => {
537
+ if (a.backup && !b.backup)
538
+ return 1;
539
+ if (!a.backup && b.backup)
540
+ return -1;
541
+ if (a.priority !== b.priority) {
542
+ return b.priority - a.priority;
543
+ }
544
+ return a.id.localeCompare(b.id);
545
+ });
546
+ }
547
+ flattenPoolTargets(pools) {
548
+ const flattened = [];
549
+ if (!Array.isArray(pools)) {
550
+ return flattened;
551
+ }
552
+ for (const pool of pools) {
553
+ if (!Array.isArray(pool.targets)) {
554
+ continue;
555
+ }
556
+ for (const target of pool.targets) {
557
+ if (typeof target === 'string' && target && !flattened.includes(target)) {
558
+ flattened.push(target);
559
+ }
560
+ }
561
+ }
562
+ return flattened;
563
+ }
368
564
  buildHitReason(routeUsed, providerKey, classification, features) {
369
565
  const reasoning = classification.reasoning || '';
370
566
  const primary = reasoning.split('|')[0] || '';
@@ -379,8 +575,8 @@ export class VirtualRouterEngine {
379
575
  if (routeUsed === 'coding') {
380
576
  return this.decorateWithDetail(primary || 'coding', primary, commandDetail);
381
577
  }
382
- if (routeUsed === 'websearch') {
383
- return this.decorateWithDetail(primary || 'websearch', primary, commandDetail);
578
+ if (routeUsed === 'web_search' || routeUsed === 'search') {
579
+ return this.decorateWithDetail(primary || routeUsed, primary, commandDetail);
384
580
  }
385
581
  if (routeUsed === DEFAULT_ROUTE && classification.fallback) {
386
582
  return primary || 'fallback:default';
@@ -406,18 +602,31 @@ export class VirtualRouterEngine {
406
602
  }
407
603
  return `${baseLabel}(${normalizedDetail})`;
408
604
  }
409
- formatVirtualRouterHit(routeName, providerKey, modelId, hitReason) {
605
+ formatVirtualRouterHit(routeName, poolId, providerKey, modelId, hitReason) {
410
606
  try {
607
+ // 生成本地时间戳
608
+ const now = new Date();
609
+ const hours = String(now.getHours()).padStart(2, '0');
610
+ const minutes = String(now.getMinutes()).padStart(2, '0');
611
+ const seconds = String(now.getSeconds()).padStart(2, '0');
612
+ const timestamp = `${hours}:${minutes}:${seconds}`;
411
613
  const prefixColor = '\x1b[38;5;208m';
412
614
  const reset = '\x1b[0m';
615
+ const timeColor = '\x1b[90m'; // 灰色
413
616
  const routeColor = this.resolveRouteColor(routeName);
414
617
  const prefix = `${prefixColor}[virtual-router-hit]${reset}`;
415
- const targetLabel = `${routeName} -> ${providerKey}${modelId ? '.' + modelId : ''}`;
618
+ const timeLabel = `${timeColor}${timestamp}${reset}`;
619
+ const { providerLabel, resolvedModel } = this.describeTargetProvider(providerKey, modelId);
620
+ const routeLabel = poolId ? `${routeName}/${poolId}` : routeName;
621
+ const targetLabel = `${routeLabel} -> ${providerLabel}${resolvedModel ? '.' + resolvedModel : ''}`;
416
622
  const reasonLabel = hitReason ? ` reason=${hitReason}` : '';
417
- return `${prefix} ${routeColor}${targetLabel}${reasonLabel}${reset}`;
623
+ return `${prefix} ${timeLabel} ${routeColor}${targetLabel}${reasonLabel}${reset}`;
418
624
  }
419
625
  catch {
420
- return `[virtual-router-hit] ${routeName} -> ${providerKey}${modelId ? '.' + modelId : ''}${hitReason ? ` reason=${hitReason}` : ''}`;
626
+ const now = new Date();
627
+ const timestamp = now.toLocaleTimeString('zh-CN', { hour12: false });
628
+ const routeLabel = poolId ? `${routeName}/${poolId}` : routeName;
629
+ return `[virtual-router-hit] ${timestamp} ${routeLabel} -> ${providerKey}${modelId ? '.' + modelId : ''}${hitReason ? ` reason=${hitReason}` : ''}`;
421
630
  }
422
631
  }
423
632
  resolveRouteColor(routeName) {
@@ -427,7 +636,8 @@ export class VirtualRouterEngine {
427
636
  thinking: '\x1b[34m',
428
637
  coding: '\x1b[35m',
429
638
  longcontext: '\x1b[38;5;141m',
430
- websearch: '\x1b[32m',
639
+ web_search: '\x1b[32m',
640
+ search: '\x1b[38;5;34m',
431
641
  vision: '\x1b[38;5;207m',
432
642
  background: '\x1b[90m'
433
643
  };
@@ -457,4 +667,31 @@ export class VirtualRouterEngine {
457
667
  }
458
668
  return `${ratio.toFixed(2)}/${Math.round(limit)}`;
459
669
  }
670
+ describeTargetProvider(providerKey, fallbackModelId) {
671
+ const parsed = this.parseProviderKey(providerKey);
672
+ if (!parsed) {
673
+ return { providerLabel: providerKey, resolvedModel: fallbackModelId };
674
+ }
675
+ const aliasLabel = parsed.keyAlias ? `${parsed.providerId}[${parsed.keyAlias}]` : parsed.providerId;
676
+ const resolvedModel = parsed.modelId || fallbackModelId;
677
+ return { providerLabel: aliasLabel, resolvedModel };
678
+ }
679
+ parseProviderKey(providerKey) {
680
+ const trimmed = typeof providerKey === 'string' ? providerKey.trim() : '';
681
+ if (!trimmed) {
682
+ return null;
683
+ }
684
+ const parts = trimmed.split('.');
685
+ if (parts.length < 2) {
686
+ return { providerId: trimmed };
687
+ }
688
+ if (parts.length === 2) {
689
+ return { providerId: parts[0], modelId: parts[1] };
690
+ }
691
+ return {
692
+ providerId: parts[0],
693
+ keyAlias: parts[1],
694
+ modelId: parts.slice(2).join('.')
695
+ };
696
+ }
460
697
  }
@@ -14,7 +14,7 @@ export function buildRoutingFeatures(request, metadata) {
14
14
  const estimatedTokens = computeRequestTokens(request, latestUserText);
15
15
  const hasThinking = detectKeyword(normalizedUserText, THINKING_KEYWORDS);
16
16
  const hasVisionTool = detectVisionTool(request);
17
- const hasImageAttachment = hasVisionTool && detectImageAttachment(latestUserMessage);
17
+ const hasImageAttachment = detectImageAttachment(latestUserMessage);
18
18
  const hasCodingTool = detectCodingTool(request);
19
19
  const hasWebTool = detectWebTool(request);
20
20
  const hasThinkingKeyword = hasThinking || detectExtendedThinkingKeyword(normalizedUserText);