@jsonstudio/llms 0.6.215 → 0.6.375

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 (73) hide show
  1. package/README.md +2 -0
  2. package/dist/conversion/codecs/gemini-openai-codec.js +92 -2
  3. package/dist/conversion/compat/actions/gemini-web-search.d.ts +17 -0
  4. package/dist/conversion/compat/actions/gemini-web-search.js +68 -0
  5. package/dist/conversion/compat/actions/glm-image-content.d.ts +2 -0
  6. package/dist/conversion/compat/actions/glm-image-content.js +83 -0
  7. package/dist/conversion/compat/actions/glm-vision-prompt.d.ts +11 -0
  8. package/dist/conversion/compat/actions/glm-vision-prompt.js +177 -0
  9. package/dist/conversion/compat/actions/glm-web-search.d.ts +2 -0
  10. package/dist/conversion/compat/actions/glm-web-search.js +63 -0
  11. package/dist/conversion/compat/actions/universal-shape-filter.js +11 -0
  12. package/dist/conversion/compat/profiles/chat-gemini.json +17 -0
  13. package/dist/conversion/compat/profiles/chat-glm.json +190 -181
  14. package/dist/conversion/compat/profiles/chat-iflow.json +195 -195
  15. package/dist/conversion/compat/profiles/chat-lmstudio.json +43 -43
  16. package/dist/conversion/compat/profiles/chat-qwen.json +20 -20
  17. package/dist/conversion/compat/profiles/responses-c4m.json +42 -42
  18. package/dist/conversion/config/sample-config.json +1 -1
  19. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +24 -0
  20. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +8 -0
  21. package/dist/conversion/hub/pipeline/hub-pipeline.js +37 -2
  22. package/dist/conversion/hub/pipeline/target-utils.js +6 -0
  23. package/dist/conversion/hub/process/chat-process.js +213 -1
  24. package/dist/conversion/hub/response/provider-response.d.ts +34 -0
  25. package/dist/conversion/hub/response/provider-response.js +84 -24
  26. package/dist/conversion/hub/response/server-side-tools.d.ts +26 -0
  27. package/dist/conversion/hub/response/server-side-tools.js +383 -0
  28. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +123 -3
  29. package/dist/conversion/hub/semantic-mappers/responses-mapper.js +17 -1
  30. package/dist/conversion/hub/standardized-bridge.js +14 -0
  31. package/dist/conversion/hub/types/standardized.d.ts +1 -0
  32. package/dist/conversion/responses/responses-openai-bridge.js +82 -3
  33. package/dist/conversion/shared/anthropic-message-utils.js +92 -3
  34. package/dist/conversion/shared/bridge-message-utils.js +137 -10
  35. package/dist/conversion/shared/responses-output-builder.js +43 -2
  36. package/dist/conversion/shared/tool-filter-pipeline.js +1 -0
  37. package/dist/conversion/shared/tool-mapping.js +25 -2
  38. package/dist/router/virtual-router/bootstrap.js +308 -43
  39. package/dist/router/virtual-router/classifier.js +11 -17
  40. package/dist/router/virtual-router/context-advisor.d.ts +0 -2
  41. package/dist/router/virtual-router/context-advisor.js +0 -12
  42. package/dist/router/virtual-router/engine.d.ts +16 -2
  43. package/dist/router/virtual-router/engine.js +317 -96
  44. package/dist/router/virtual-router/features.js +1 -1
  45. package/dist/router/virtual-router/message-utils.js +36 -24
  46. package/dist/router/virtual-router/provider-registry.js +2 -1
  47. package/dist/router/virtual-router/token-counter.js +14 -3
  48. package/dist/router/virtual-router/types.d.ts +66 -2
  49. package/dist/router/virtual-router/types.js +2 -1
  50. package/dist/servertool/engine.d.ts +27 -0
  51. package/dist/servertool/engine.js +60 -0
  52. package/dist/servertool/flow-types.d.ts +40 -0
  53. package/dist/servertool/flow-types.js +1 -0
  54. package/dist/servertool/handlers/vision.d.ts +1 -0
  55. package/dist/servertool/handlers/vision.js +194 -0
  56. package/dist/servertool/handlers/web-search.d.ts +1 -0
  57. package/dist/servertool/handlers/web-search.js +638 -0
  58. package/dist/servertool/orchestration-types.d.ts +33 -0
  59. package/dist/servertool/orchestration-types.js +1 -0
  60. package/dist/servertool/registry.d.ts +18 -0
  61. package/dist/servertool/registry.js +27 -0
  62. package/dist/servertool/server-side-tools.d.ts +8 -0
  63. package/dist/servertool/server-side-tools.js +208 -0
  64. package/dist/servertool/types.d.ts +88 -0
  65. package/dist/servertool/types.js +1 -0
  66. package/dist/servertool/vision-tool.d.ts +2 -0
  67. package/dist/servertool/vision-tool.js +185 -0
  68. package/dist/sse/json-to-sse/event-generators/responses.js +15 -3
  69. package/dist/sse/sse-to-json/builders/response-builder.js +6 -3
  70. package/dist/sse/sse-to-json/gemini-sse-to-json-converter.js +27 -1
  71. package/dist/sse/types/gemini-types.d.ts +20 -1
  72. package/dist/sse/types/responses-types.js +1 -1
  73. package/package.json +1 -1
@@ -18,6 +18,8 @@ export class VirtualRouterEngine {
18
18
  debug = console; // thin hook; host may monkey-patch for colored logging
19
19
  healthConfig = null;
20
20
  statsCenter = getStatsCenter();
21
+ // Derived flags from VirtualRouterConfig/routing used by process / response layers.
22
+ webSearchForce = false;
21
23
  initialize(config) {
22
24
  this.validateConfig(config);
23
25
  this.routing = config.routing;
@@ -29,6 +31,7 @@ export class VirtualRouterEngine {
29
31
  this.classifier = new RoutingClassifier(config.classifier);
30
32
  this.contextRouting = config.contextRouting ?? { warnRatio: 0.9, hardLimit: false };
31
33
  this.contextAdvisor.configure(this.contextRouting);
34
+ this.webSearchForce = config.webSearch?.force === true;
32
35
  this.routeStats = new Map();
33
36
  for (const routeName of Object.keys(this.routing)) {
34
37
  this.routeStats.set(routeName, { hits: 0 });
@@ -36,10 +39,24 @@ export class VirtualRouterEngine {
36
39
  }
37
40
  route(request, metadata) {
38
41
  const features = buildRoutingFeatures(request, metadata);
39
- const classification = this.classifier.classify(features);
40
- const routeName = classification.routeName || DEFAULT_ROUTE;
41
- const selection = this.selectProvider(routeName, metadata, classification, features);
42
- 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
+ };
43
60
  this.healthManager.recordSuccess(selection.providerKey);
44
61
  this.incrementRouteStat(selection.routeUsed, selection.providerKey);
45
62
  try {
@@ -48,7 +65,7 @@ export class VirtualRouterEngine {
48
65
  timestamp: Date.now(),
49
66
  entryEndpoint: metadata.entryEndpoint || '/v1/chat/completions',
50
67
  routeName: selection.routeUsed,
51
- pool: selection.routeUsed,
68
+ pool: selection.poolId || selection.routeUsed,
52
69
  providerKey: selection.providerKey,
53
70
  modelId: target.modelId || undefined
54
71
  });
@@ -57,20 +74,21 @@ export class VirtualRouterEngine {
57
74
  // stats must never break routing
58
75
  }
59
76
  const hitReason = this.buildHitReason(selection.routeUsed, selection.providerKey, classification, features);
60
- 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);
61
78
  if (formatted) {
62
79
  this.debug?.log?.(formatted);
63
80
  }
64
81
  else {
65
82
  this.debug?.log?.('[virtual-router-hit]', selection.routeUsed, selection.providerKey, target.modelId || '', hitReason ? `reason=${hitReason}` : '');
66
83
  }
67
- const didFallback = selection.routeUsed !== routeName || classification.fallback;
84
+ const didFallback = selection.routeUsed !== requestedRoute;
68
85
  return {
69
86
  target,
70
87
  decision: {
71
88
  routeName: selection.routeUsed,
72
89
  providerKey: selection.providerKey,
73
90
  pool: selection.pool,
91
+ poolId: selection.poolId,
74
92
  confidence: classification.confidence,
75
93
  reasoning: classification.reasoning,
76
94
  fallback: didFallback
@@ -79,6 +97,7 @@ export class VirtualRouterEngine {
79
97
  routeName: selection.routeUsed,
80
98
  providerKey: selection.providerKey,
81
99
  pool: selection.pool,
100
+ poolId: selection.poolId,
82
101
  reasoning: classification.reasoning,
83
102
  fallback: didFallback,
84
103
  confidence: classification.confidence
@@ -108,10 +127,10 @@ export class VirtualRouterEngine {
108
127
  }
109
128
  getStatus() {
110
129
  const routes = {};
111
- for (const [route, pool] of Object.entries(this.routing)) {
130
+ for (const [route, pools] of Object.entries(this.routing)) {
112
131
  const stats = this.routeStats.get(route) ?? { hits: 0 };
113
132
  routes[route] = {
114
- providers: [...pool],
133
+ providers: this.flattenPoolTargets(pools),
115
134
  hits: stats.hits,
116
135
  lastUsedProvider: stats.lastProvider
117
136
  };
@@ -121,6 +140,14 @@ export class VirtualRouterEngine {
121
140
  health: this.healthManager.getSnapshot()
122
141
  };
123
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
+ }
124
151
  validateConfig(config) {
125
152
  if (!config.routing || typeof config.routing !== 'object') {
126
153
  throw new VirtualRouterError('routing configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
@@ -128,32 +155,39 @@ export class VirtualRouterEngine {
128
155
  if (!config.providers || Object.keys(config.providers).length === 0) {
129
156
  throw new VirtualRouterError('providers configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
130
157
  }
131
- const defaultPool = config.routing[DEFAULT_ROUTE];
132
- if (!Array.isArray(defaultPool) || defaultPool.length === 0) {
158
+ const defaultPools = config.routing[DEFAULT_ROUTE];
159
+ if (!this.routeHasTargets(defaultPools)) {
133
160
  throw new VirtualRouterError('default route must be configured with at least one provider', VirtualRouterErrorCode.CONFIG_ERROR);
134
161
  }
162
+ if (!this.hasPrimaryPool(defaultPools)) {
163
+ throw new VirtualRouterError('default route must define at least one non-backup pool', VirtualRouterErrorCode.CONFIG_ERROR);
164
+ }
135
165
  const providerKeys = new Set(Object.keys(config.providers));
136
- for (const [routeName, pool] of Object.entries(config.routing)) {
137
- if (!Array.isArray(pool) || !pool.length) {
166
+ for (const [routeName, pools] of Object.entries(config.routing)) {
167
+ if (!this.routeHasTargets(pools)) {
138
168
  if (routeName === DEFAULT_ROUTE) {
139
169
  throw new VirtualRouterError('default route cannot be empty', VirtualRouterErrorCode.CONFIG_ERROR);
140
170
  }
141
171
  continue;
142
172
  }
143
- for (const providerKey of pool) {
144
- if (!providerKeys.has(providerKey)) {
145
- 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
+ }
146
181
  }
147
182
  }
148
183
  }
149
184
  }
150
185
  selectProvider(requestedRoute, metadata, classification, features) {
151
- const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
186
+ const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates, features);
152
187
  const stickyKey = this.resolveStickyKey(metadata);
153
188
  const attempted = [];
154
189
  const visitedRoutes = new Set();
155
- const fallbackRoute = this.resolveFallbackRoute();
156
- const routeQueue = this.initializeRouteQueue(candidates, fallbackRoute);
190
+ const routeQueue = this.initializeRouteQueue(candidates);
157
191
  const estimatedTokens = typeof features.estimatedTokens === 'number' && Number.isFinite(features.estimatedTokens)
158
192
  ? Math.max(0, features.estimatedTokens)
159
193
  : 0;
@@ -162,33 +196,93 @@ export class VirtualRouterEngine {
162
196
  if (visitedRoutes.has(routeName)) {
163
197
  continue;
164
198
  }
165
- const pool = this.routing[routeName];
166
- if (!Array.isArray(pool) || pool.length === 0) {
199
+ const routePools = this.routing[routeName];
200
+ if (!this.routeHasTargets(routePools)) {
167
201
  visitedRoutes.add(routeName);
168
- attempted.push(routeName);
169
- continue;
170
- }
171
- const contextResult = this.contextAdvisor.classify(pool, estimatedTokens, (key) => this.providerRegistry.get(key));
172
- if (this.maybeDeferToFallback(routeName, contextResult, routeQueue, visitedRoutes, fallbackRoute)) {
202
+ attempted.push(`${routeName}:empty`);
173
203
  continue;
174
204
  }
175
205
  visitedRoutes.add(routeName);
176
- const prioritizedPools = this.buildContextCandidatePools(contextResult);
177
- for (const candidatePool of prioritizedPools) {
178
- const providerKey = this.loadBalancer.select({
179
- routeName,
180
- candidates: candidatePool,
181
- stickyKey,
182
- availabilityCheck: (key) => this.healthManager.isAvailable(key)
183
- });
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);
184
209
  if (providerKey) {
185
- return { providerKey, routeUsed: routeName, pool };
210
+ return { providerKey, routeUsed: routeName, pool: poolTargets, poolId: tierId };
211
+ }
212
+ if (failureHint) {
213
+ attempted.push(failureHint);
186
214
  }
187
215
  }
188
- attempted.push(this.describeAttempt(routeName, contextResult));
189
216
  }
190
217
  throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
191
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
+ }
192
286
  incrementRouteStat(routeName, providerKey) {
193
287
  if (!this.routeStats.has(routeName)) {
194
288
  this.routeStats.set(routeName, { hits: 0, lastProvider: providerKey });
@@ -201,63 +295,34 @@ export class VirtualRouterEngine {
201
295
  providerHealthConfig() {
202
296
  return this.healthManager.getConfig();
203
297
  }
204
- initializeRouteQueue(candidates, fallbackRoute) {
205
- const queue = Array.from(new Set(candidates));
206
- if (fallbackRoute && !queue.includes(fallbackRoute)) {
207
- queue.push(fallbackRoute);
208
- }
209
- return queue;
210
- }
211
- resolveFallbackRoute() {
212
- const candidate = this.contextRouting?.fallbackRoute;
213
- if (!candidate) {
214
- return undefined;
215
- }
216
- const pool = this.routing[candidate];
217
- if (!Array.isArray(pool) || pool.length === 0) {
218
- return undefined;
219
- }
220
- return candidate;
221
- }
222
- maybeDeferToFallback(routeName, contextResult, queue, visited, fallbackRoute) {
223
- if (!fallbackRoute || fallbackRoute === routeName || visited.has(fallbackRoute)) {
224
- return false;
225
- }
226
- if (!this.contextAdvisor.prefersFallback(contextResult)) {
227
- return false;
228
- }
229
- const fallbackPool = this.routing[fallbackRoute];
230
- if (!Array.isArray(fallbackPool) || fallbackPool.length === 0) {
231
- return false;
232
- }
233
- queue.unshift(routeName);
234
- queue.unshift(fallbackRoute);
235
- return true;
298
+ initializeRouteQueue(candidates) {
299
+ return Array.from(new Set(candidates));
236
300
  }
237
301
  buildContextCandidatePools(result) {
238
302
  const ordered = [];
239
303
  if (result.safe.length) {
240
304
  ordered.push(result.safe);
305
+ // 如果存在安全候选,直接放弃当前处于警戒阈值的模型
306
+ return ordered;
241
307
  }
242
308
  if (result.risky.length) {
243
309
  ordered.push(result.risky);
244
310
  }
245
- if (result.overflow.length && this.contextAdvisor.allowsOverflow()) {
246
- ordered.push(result.overflow);
247
- }
311
+ // ratio >= 1 视为上下文溢出,直接标记为不可用
248
312
  return ordered;
249
313
  }
250
- describeAttempt(routeName, result) {
314
+ describeAttempt(routeName, poolId, result) {
315
+ const prefix = poolId ? `${routeName}:${poolId}` : routeName;
251
316
  if (result.safe.length > 0) {
252
- return `${routeName}:health`;
317
+ return `${prefix}:health`;
253
318
  }
254
319
  if (result.risky.length > 0) {
255
- return `${routeName}:context_risky`;
320
+ return `${prefix}:context_risky`;
256
321
  }
257
322
  if (result.overflow.length > 0) {
258
- return `${routeName}:context_overflow`;
323
+ return `${prefix}:max_context_window`;
259
324
  }
260
- return routeName;
325
+ return prefix;
261
326
  }
262
327
  resolveStickyKey(metadata) {
263
328
  const resume = metadata.responsesResume;
@@ -348,12 +413,32 @@ export class VirtualRouterEngine {
348
413
  return 'client_error';
349
414
  return 'unknown';
350
415
  }
351
- buildRouteCandidates(requestedRoute, classificationCandidates) {
352
- const normalized = requestedRoute || DEFAULT_ROUTE;
353
- const baseList = classificationCandidates && classificationCandidates.length
354
- ? classificationCandidates
355
- : [normalized];
356
- 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
+ }
357
442
  const deduped = [];
358
443
  for (const routeName of ordered) {
359
444
  if (routeName && !deduped.includes(routeName)) {
@@ -363,17 +448,59 @@ export class VirtualRouterEngine {
363
448
  if (!deduped.includes(DEFAULT_ROUTE)) {
364
449
  deduped.push(DEFAULT_ROUTE);
365
450
  }
366
- const filtered = deduped.filter((routeName) => {
367
- const pool = this.routing[routeName];
368
- return Array.isArray(pool) && pool.length > 0;
369
- });
370
- if (!filtered.includes(DEFAULT_ROUTE) &&
371
- Array.isArray(this.routing[DEFAULT_ROUTE]) &&
372
- 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])) {
373
453
  filtered.push(DEFAULT_ROUTE);
374
454
  }
375
455
  return filtered.length ? filtered : [DEFAULT_ROUTE];
376
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
+ }
377
504
  sortByPriority(routeNames) {
378
505
  return [...routeNames].sort((a, b) => this.routeWeight(a) - this.routeWeight(b));
379
506
  }
@@ -381,6 +508,59 @@ export class VirtualRouterEngine {
381
508
  const idx = ROUTE_PRIORITY.indexOf(routeName);
382
509
  return idx >= 0 ? idx : ROUTE_PRIORITY.length;
383
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
+ }
384
564
  buildHitReason(routeUsed, providerKey, classification, features) {
385
565
  const reasoning = classification.reasoning || '';
386
566
  const primary = reasoning.split('|')[0] || '';
@@ -395,8 +575,8 @@ export class VirtualRouterEngine {
395
575
  if (routeUsed === 'coding') {
396
576
  return this.decorateWithDetail(primary || 'coding', primary, commandDetail);
397
577
  }
398
- if (routeUsed === 'websearch') {
399
- return this.decorateWithDetail(primary || 'websearch', primary, commandDetail);
578
+ if (routeUsed === 'web_search' || routeUsed === 'search') {
579
+ return this.decorateWithDetail(primary || routeUsed, primary, commandDetail);
400
580
  }
401
581
  if (routeUsed === DEFAULT_ROUTE && classification.fallback) {
402
582
  return primary || 'fallback:default';
@@ -422,18 +602,31 @@ export class VirtualRouterEngine {
422
602
  }
423
603
  return `${baseLabel}(${normalizedDetail})`;
424
604
  }
425
- formatVirtualRouterHit(routeName, providerKey, modelId, hitReason) {
605
+ formatVirtualRouterHit(routeName, poolId, providerKey, modelId, hitReason) {
426
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}`;
427
613
  const prefixColor = '\x1b[38;5;208m';
428
614
  const reset = '\x1b[0m';
615
+ const timeColor = '\x1b[90m'; // 灰色
429
616
  const routeColor = this.resolveRouteColor(routeName);
430
617
  const prefix = `${prefixColor}[virtual-router-hit]${reset}`;
431
- 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 : ''}`;
432
622
  const reasonLabel = hitReason ? ` reason=${hitReason}` : '';
433
- return `${prefix} ${routeColor}${targetLabel}${reasonLabel}${reset}`;
623
+ return `${prefix} ${timeLabel} ${routeColor}${targetLabel}${reasonLabel}${reset}`;
434
624
  }
435
625
  catch {
436
- 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}` : ''}`;
437
630
  }
438
631
  }
439
632
  resolveRouteColor(routeName) {
@@ -443,7 +636,8 @@ export class VirtualRouterEngine {
443
636
  thinking: '\x1b[34m',
444
637
  coding: '\x1b[35m',
445
638
  longcontext: '\x1b[38;5;141m',
446
- websearch: '\x1b[32m',
639
+ web_search: '\x1b[32m',
640
+ search: '\x1b[38;5;34m',
447
641
  vision: '\x1b[38;5;207m',
448
642
  background: '\x1b[90m'
449
643
  };
@@ -473,4 +667,31 @@ export class VirtualRouterEngine {
473
667
  }
474
668
  return `${ratio.toFixed(2)}/${Math.round(limit)}`;
475
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
+ }
476
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);