@jsonstudio/llms 0.6.230 → 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 (63) hide show
  1. package/README.md +2 -0
  2. package/dist/conversion/codecs/gemini-openai-codec.js +9 -1
  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.js +25 -28
  10. package/dist/conversion/compat/actions/universal-shape-filter.js +11 -0
  11. package/dist/conversion/compat/profiles/chat-gemini.json +17 -0
  12. package/dist/conversion/compat/profiles/chat-glm.json +190 -184
  13. package/dist/conversion/compat/profiles/chat-iflow.json +195 -195
  14. package/dist/conversion/compat/profiles/chat-lmstudio.json +43 -43
  15. package/dist/conversion/compat/profiles/chat-qwen.json +20 -20
  16. package/dist/conversion/compat/profiles/responses-c4m.json +42 -42
  17. package/dist/conversion/config/sample-config.json +1 -1
  18. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +18 -0
  19. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +6 -0
  20. package/dist/conversion/hub/pipeline/hub-pipeline.js +28 -1
  21. package/dist/conversion/hub/pipeline/target-utils.js +6 -0
  22. package/dist/conversion/hub/process/chat-process.js +100 -18
  23. package/dist/conversion/hub/response/provider-response.d.ts +13 -1
  24. package/dist/conversion/hub/response/provider-response.js +84 -35
  25. package/dist/conversion/hub/response/server-side-tools.js +61 -4
  26. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +123 -3
  27. package/dist/conversion/hub/semantic-mappers/responses-mapper.js +17 -1
  28. package/dist/conversion/hub/standardized-bridge.js +14 -0
  29. package/dist/conversion/responses/responses-openai-bridge.js +35 -2
  30. package/dist/conversion/shared/anthropic-message-utils.js +92 -3
  31. package/dist/conversion/shared/bridge-message-utils.js +137 -10
  32. package/dist/conversion/shared/responses-output-builder.js +43 -2
  33. package/dist/conversion/shared/tool-filter-pipeline.js +1 -0
  34. package/dist/router/virtual-router/bootstrap.js +44 -12
  35. package/dist/router/virtual-router/classifier.js +11 -17
  36. package/dist/router/virtual-router/engine.d.ts +9 -0
  37. package/dist/router/virtual-router/engine.js +160 -18
  38. package/dist/router/virtual-router/features.js +1 -1
  39. package/dist/router/virtual-router/message-utils.js +36 -24
  40. package/dist/router/virtual-router/provider-registry.js +2 -1
  41. package/dist/router/virtual-router/token-counter.js +14 -3
  42. package/dist/router/virtual-router/types.d.ts +45 -0
  43. package/dist/router/virtual-router/types.js +2 -1
  44. package/dist/servertool/engine.d.ts +27 -0
  45. package/dist/servertool/engine.js +60 -0
  46. package/dist/servertool/flow-types.d.ts +40 -0
  47. package/dist/servertool/flow-types.js +1 -0
  48. package/dist/servertool/handlers/vision.d.ts +1 -0
  49. package/dist/servertool/handlers/vision.js +194 -0
  50. package/dist/servertool/handlers/web-search.d.ts +1 -0
  51. package/dist/servertool/handlers/web-search.js +638 -0
  52. package/dist/servertool/orchestration-types.d.ts +33 -0
  53. package/dist/servertool/orchestration-types.js +1 -0
  54. package/dist/servertool/registry.d.ts +18 -0
  55. package/dist/servertool/registry.js +27 -0
  56. package/dist/servertool/server-side-tools.d.ts +8 -0
  57. package/dist/servertool/server-side-tools.js +208 -0
  58. package/dist/servertool/types.d.ts +88 -0
  59. package/dist/servertool/types.js +1 -0
  60. package/dist/servertool/vision-tool.d.ts +2 -0
  61. package/dist/servertool/vision-tool.js +185 -0
  62. package/dist/sse/sse-to-json/builders/response-builder.js +6 -3
  63. package/package.json +1 -1
@@ -27,7 +27,7 @@ export function bootstrapVirtualRouterConfig(input) {
27
27
  if (!Object.keys(routingSource).length) {
28
28
  throw new VirtualRouterError('Virtual Router routing table cannot be empty', VirtualRouterErrorCode.CONFIG_ERROR);
29
29
  }
30
- const webSearch = normalizeWebSearch(section.webSearch);
30
+ const webSearch = normalizeWebSearch(section.webSearch, routingSource);
31
31
  validateWebSearchRouting(webSearch, routingSource);
32
32
  const { runtimeEntries, aliasIndex } = buildProviderRuntimeEntries(providersSource);
33
33
  const { routing, targetKeys } = expandRoutingTable(routingSource, aliasIndex);
@@ -121,7 +121,8 @@ function buildProviderRuntimeEntries(providers) {
121
121
  streaming: normalizedProvider.streaming,
122
122
  modelStreaming: normalizedProvider.modelStreaming,
123
123
  modelContextTokens: normalizedProvider.modelContextTokens,
124
- defaultContextTokens: normalizedProvider.defaultContextTokens
124
+ defaultContextTokens: normalizedProvider.defaultContextTokens,
125
+ ...(normalizedProvider.serverToolsDisabled ? { serverToolsDisabled: true } : {})
125
126
  };
126
127
  }
127
128
  }
@@ -158,7 +159,8 @@ function expandRoutingTable(routingSource, aliasIndex) {
158
159
  id: pool.id,
159
160
  priority: pool.priority,
160
161
  backup: pool.backup,
161
- targets: expandedTargets
162
+ targets: expandedTargets,
163
+ ...(pool.force ? { force: true } : {})
162
164
  });
163
165
  }
164
166
  }
@@ -194,7 +196,8 @@ function buildProviderProfiles(targetKeys, runtimeEntries) {
194
196
  processMode: runtime.processMode || 'chat',
195
197
  responsesConfig: runtime.responsesConfig,
196
198
  streaming: streamingPref,
197
- maxContextTokens: contextTokens
199
+ maxContextTokens: contextTokens,
200
+ ...(runtime.serverToolsDisabled ? { serverToolsDisabled: true } : {})
198
201
  };
199
202
  targetRuntime[targetKey] = {
200
203
  ...runtime,
@@ -274,12 +277,15 @@ function normalizeRoutePoolEntry(routeName, entry, index, total) {
274
277
  (typeof record.type === 'string' && record.type.toLowerCase() === 'backup');
275
278
  const priority = normalizePriorityValue(record.priority, total - index);
276
279
  const targets = normalizeRouteTargets(record);
280
+ const force = record.force === true ||
281
+ (typeof record.force === 'string' && record.force.trim().toLowerCase() === 'true');
277
282
  return targets.length
278
283
  ? {
279
284
  id,
280
285
  priority,
281
286
  backup,
282
- targets
287
+ targets,
288
+ ...(force ? { force: true } : {})
283
289
  }
284
290
  : null;
285
291
  }
@@ -386,6 +392,12 @@ function normalizeProvider(providerId, raw) {
386
392
  const streaming = resolveProviderStreamingPreference(provider, responsesNode);
387
393
  const modelStreaming = normalizeModelStreaming(provider);
388
394
  const { modelContextTokens, defaultContextTokens } = normalizeModelContextTokens(provider);
395
+ const serverToolsDisabled = provider.serverToolsDisabled === true ||
396
+ (typeof provider.serverToolsDisabled === 'string' &&
397
+ provider.serverToolsDisabled.trim().toLowerCase() === 'true') ||
398
+ (provider.serverTools &&
399
+ typeof provider.serverTools === 'object' &&
400
+ provider.serverTools.enabled === false);
389
401
  return {
390
402
  providerId,
391
403
  providerType,
@@ -398,7 +410,8 @@ function normalizeProvider(providerId, raw) {
398
410
  streaming,
399
411
  modelStreaming,
400
412
  modelContextTokens,
401
- defaultContextTokens
413
+ defaultContextTokens,
414
+ ...(serverToolsDisabled ? { serverToolsDisabled: true } : {})
402
415
  };
403
416
  }
404
417
  function normalizeModelStreaming(provider) {
@@ -539,9 +552,9 @@ function validateWebSearchRouting(webSearch, routingSource) {
539
552
  if (!webSearch) {
540
553
  return;
541
554
  }
542
- const routePools = routingSource['web_search'];
555
+ const routePools = routingSource['web_search'] ?? routingSource['search'];
543
556
  if (!Array.isArray(routePools) || !routePools.length) {
544
- throw new VirtualRouterError('Virtual Router webSearch.engines configured but routing.web_search route is missing or empty', VirtualRouterErrorCode.CONFIG_ERROR);
557
+ throw new VirtualRouterError('Virtual Router webSearch.engines configured but routing.web_search (or search) route is missing or empty', VirtualRouterErrorCode.CONFIG_ERROR);
545
558
  }
546
559
  const targets = new Set();
547
560
  for (const pool of routePools) {
@@ -556,11 +569,11 @@ function validateWebSearchRouting(webSearch, routingSource) {
556
569
  }
557
570
  for (const engine of webSearch.engines) {
558
571
  if (!targets.has(engine.providerKey)) {
559
- throw new VirtualRouterError(`Virtual Router webSearch engine "${engine.id}" references providerKey "${engine.providerKey}" which is not present in routing.web_search`, VirtualRouterErrorCode.CONFIG_ERROR);
572
+ throw new VirtualRouterError(`Virtual Router webSearch engine "${engine.id}" references providerKey "${engine.providerKey}" which is not present in routing.web_search/search`, VirtualRouterErrorCode.CONFIG_ERROR);
560
573
  }
561
574
  }
562
575
  }
563
- function normalizeWebSearch(input) {
576
+ function normalizeWebSearch(input, routingSource) {
564
577
  if (!input || typeof input !== 'object') {
565
578
  return undefined;
566
579
  }
@@ -588,6 +601,12 @@ function normalizeWebSearch(input) {
588
601
  : undefined;
589
602
  const isDefault = node.default === true ||
590
603
  (typeof node.default === 'string' && node.default.trim().toLowerCase() === 'true');
604
+ const serverToolsDisabled = node.serverToolsDisabled === true ||
605
+ (typeof node.serverToolsDisabled === 'string' &&
606
+ node.serverToolsDisabled.trim().toLowerCase() === 'true') ||
607
+ (node.serverTools &&
608
+ typeof node.serverTools === 'object' &&
609
+ node.serverTools.enabled === false);
591
610
  // Deduplicate by id; first wins, subsequent are ignored.
592
611
  if (engines.some((engine) => engine.id === id)) {
593
612
  continue;
@@ -596,13 +615,15 @@ function normalizeWebSearch(input) {
596
615
  id,
597
616
  providerKey,
598
617
  description,
599
- default: isDefault
618
+ default: isDefault,
619
+ ...(serverToolsDisabled ? { serverToolsDisabled: true } : {})
600
620
  });
601
621
  }
602
622
  if (!engines.length) {
603
623
  return undefined;
604
624
  }
605
625
  let injectPolicy;
626
+ let force;
606
627
  const rawPolicy = record.injectPolicy ?? record?.inject_policy;
607
628
  if (typeof rawPolicy === 'string') {
608
629
  const normalized = rawPolicy.trim().toLowerCase();
@@ -610,9 +631,20 @@ function normalizeWebSearch(input) {
610
631
  injectPolicy = normalized;
611
632
  }
612
633
  }
634
+ if (record.force === true ||
635
+ (typeof record.force === 'string' && record.force.trim().toLowerCase() === 'true')) {
636
+ force = true;
637
+ }
638
+ else {
639
+ const webSearchPools = routingSource['web_search'] ?? routingSource['search'] ?? [];
640
+ if (Array.isArray(webSearchPools) && webSearchPools.some((pool) => pool.force)) {
641
+ force = true;
642
+ }
643
+ }
613
644
  return {
614
645
  engines,
615
- injectPolicy: injectPolicy ?? 'selective'
646
+ injectPolicy: injectPolicy ?? 'selective',
647
+ ...(force ? { force } : {})
616
648
  };
617
649
  }
618
650
  function extractProviderAuthEntries(providerId, raw) {
@@ -17,34 +17,28 @@ export class RoutingClassifier {
17
17
  const thinkingContinuation = lastToolCategory === 'read';
18
18
  const searchContinuation = lastToolCategory === 'search';
19
19
  const toolsContinuation = lastToolCategory === 'other';
20
- if (latestMessageFromUser) {
21
- const reasoning = 'thinking:user-input';
22
- const evaluations = {
23
- thinking: { triggered: true, reason: reasoning }
24
- };
25
- const candidates = this.ensureDefaultCandidate(['thinking']);
26
- return this.buildResult('thinking', reasoning, evaluations, candidates);
27
- }
20
+ // 用户输入优先级最高(仅次于 vision),确保每次新的用户输入都走 thinking 路由进行工具检查
21
+ // thinking_continuation 用于区分"工具轮次中的 read 类调用"与"用户新输入"
28
22
  const evaluationMap = {
29
23
  vision: {
30
- triggered: features.hasVisionTool && features.hasImageAttachment,
31
- reason: 'vision:requires-tool+image'
24
+ triggered: features.hasImageAttachment,
25
+ reason: 'vision:image-detected'
26
+ },
27
+ thinking: {
28
+ triggered: latestMessageFromUser,
29
+ reason: 'thinking:user-input'
32
30
  },
33
31
  longcontext: {
34
32
  triggered: reachedLongContext,
35
33
  reason: 'longcontext:token-threshold'
36
34
  },
37
- websearch: {
38
- triggered: features.hasWebTool || searchContinuation,
39
- reason: searchContinuation ? 'websearch:last-tool-search' : 'websearch:web-tools-detected'
40
- },
41
35
  coding: {
42
36
  triggered: codingContinuation,
43
37
  reason: 'coding:last-tool-write'
44
38
  },
45
- thinking: {
46
- triggered: thinkingContinuation || latestMessageFromUser,
47
- reason: thinkingContinuation ? 'thinking:last-tool-read' : 'thinking:user-input'
39
+ thinking_continuation: {
40
+ triggered: thinkingContinuation,
41
+ reason: 'thinking:last-tool-read'
48
42
  },
49
43
  tools: {
50
44
  triggered: toolsContinuation || features.hasTools || features.hasToolCallResponses,
@@ -12,6 +12,7 @@ export declare class VirtualRouterEngine {
12
12
  private readonly debug;
13
13
  private healthConfig;
14
14
  private readonly statsCenter;
15
+ private webSearchForce;
15
16
  initialize(config: VirtualRouterConfig): void;
16
17
  route(request: StandardizedRequest | ProcessedRequest, metadata: RouterMetadataInput): {
17
18
  target: TargetMetadata;
@@ -28,6 +29,11 @@ export declare class VirtualRouterEngine {
28
29
  }>;
29
30
  health: import("./types.js").ProviderHealthState[];
30
31
  };
32
+ /**
33
+ * 将分类器产生的逻辑路由名直接归一化为配置中的路由键。
34
+ * 不再维护 "websearch" 之类的别名,调用方应显式使用 "web_search" 或 "search" 等实际路由名。
35
+ */
36
+ private normalizeRouteAlias;
31
37
  private validateConfig;
32
38
  private selectProvider;
33
39
  private trySelectFromTier;
@@ -40,8 +46,11 @@ export declare class VirtualRouterEngine {
40
46
  private mapProviderError;
41
47
  private deriveReason;
42
48
  private buildRouteCandidates;
49
+ private reorderForInlineVision;
50
+ private routeSupportsInlineVision;
43
51
  private sortByPriority;
44
52
  private routeWeight;
53
+ private routeHasForceFlag;
45
54
  private routeHasTargets;
46
55
  private hasPrimaryPool;
47
56
  private sortRoutePools;
@@ -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 {
@@ -64,7 +81,7 @@ export class VirtualRouterEngine {
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;
84
+ const didFallback = selection.routeUsed !== requestedRoute;
68
85
  return {
69
86
  target,
70
87
  decision: {
@@ -123,6 +140,14 @@ export class VirtualRouterEngine {
123
140
  health: this.healthManager.getSnapshot()
124
141
  };
125
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
+ }
126
151
  validateConfig(config) {
127
152
  if (!config.routing || typeof config.routing !== 'object') {
128
153
  throw new VirtualRouterError('routing configuration is required', VirtualRouterErrorCode.CONFIG_ERROR);
@@ -158,7 +183,7 @@ export class VirtualRouterEngine {
158
183
  }
159
184
  }
160
185
  selectProvider(requestedRoute, metadata, classification, features) {
161
- const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates);
186
+ const candidates = this.buildRouteCandidates(requestedRoute, classification.candidates, features);
162
187
  const stickyKey = this.resolveStickyKey(metadata);
163
188
  const attempted = [];
164
189
  const visitedRoutes = new Set();
@@ -180,7 +205,7 @@ export class VirtualRouterEngine {
180
205
  visitedRoutes.add(routeName);
181
206
  const orderedPools = this.sortRoutePools(routePools);
182
207
  for (const poolTier of orderedPools) {
183
- const { providerKey, poolTargets, tierId, failureHint } = this.trySelectFromTier(routeName, poolTier, stickyKey, estimatedTokens);
208
+ const { providerKey, poolTargets, tierId, failureHint } = this.trySelectFromTier(routeName, poolTier, stickyKey, estimatedTokens, features);
184
209
  if (providerKey) {
185
210
  return { providerKey, routeUsed: routeName, pool: poolTargets, poolId: tierId };
186
211
  }
@@ -191,8 +216,50 @@ export class VirtualRouterEngine {
191
216
  }
192
217
  throw new VirtualRouterError(`All providers unavailable for route ${requestedRoute}`, VirtualRouterErrorCode.PROVIDER_NOT_AVAILABLE, { routeName: requestedRoute, attempted });
193
218
  }
194
- trySelectFromTier(routeName, tier, stickyKey, estimatedTokens) {
195
- const targets = Array.isArray(tier.targets) ? tier.targets : [];
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
+ }
196
263
  if (!targets.length) {
197
264
  return { providerKey: null, poolTargets: [], tierId: tier.id, failureHint: `${routeName}:${tier.id}:empty` };
198
265
  }
@@ -346,12 +413,32 @@ export class VirtualRouterEngine {
346
413
  return 'client_error';
347
414
  return 'unknown';
348
415
  }
349
- buildRouteCandidates(requestedRoute, classificationCandidates) {
350
- const normalized = requestedRoute || DEFAULT_ROUTE;
351
- const baseList = classificationCandidates && classificationCandidates.length
352
- ? classificationCandidates
353
- : [normalized];
354
- 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
+ }
355
442
  const deduped = [];
356
443
  for (const routeName of ordered) {
357
444
  if (routeName && !deduped.includes(routeName)) {
@@ -367,6 +454,53 @@ export class VirtualRouterEngine {
367
454
  }
368
455
  return filtered.length ? filtered : [DEFAULT_ROUTE];
369
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
+ }
370
504
  sortByPriority(routeNames) {
371
505
  return [...routeNames].sort((a, b) => this.routeWeight(a) - this.routeWeight(b));
372
506
  }
@@ -374,6 +508,13 @@ export class VirtualRouterEngine {
374
508
  const idx = ROUTE_PRIORITY.indexOf(routeName);
375
509
  return idx >= 0 ? idx : ROUTE_PRIORITY.length;
376
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
+ }
377
518
  routeHasTargets(pools) {
378
519
  if (!Array.isArray(pools)) {
379
520
  return false;
@@ -434,8 +575,8 @@ export class VirtualRouterEngine {
434
575
  if (routeUsed === 'coding') {
435
576
  return this.decorateWithDetail(primary || 'coding', primary, commandDetail);
436
577
  }
437
- if (routeUsed === 'websearch') {
438
- return this.decorateWithDetail(primary || 'websearch', primary, commandDetail);
578
+ if (routeUsed === 'web_search' || routeUsed === 'search') {
579
+ return this.decorateWithDetail(primary || routeUsed, primary, commandDetail);
439
580
  }
440
581
  if (routeUsed === DEFAULT_ROUTE && classification.fallback) {
441
582
  return primary || 'fallback:default';
@@ -495,7 +636,8 @@ export class VirtualRouterEngine {
495
636
  thinking: '\x1b[34m',
496
637
  coding: '\x1b[35m',
497
638
  longcontext: '\x1b[38;5;141m',
498
- websearch: '\x1b[32m',
639
+ web_search: '\x1b[32m',
640
+ search: '\x1b[38;5;34m',
499
641
  vision: '\x1b[38;5;207m',
500
642
  background: '\x1b[90m'
501
643
  };
@@ -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);
@@ -37,30 +37,42 @@ export function detectExtendedThinkingKeyword(text) {
37
37
  export function detectImageAttachment(message) {
38
38
  if (!message)
39
39
  return false;
40
- if (!message.metadata || typeof message.metadata !== 'object') {
41
- return false;
42
- }
43
- const meta = message.metadata;
44
- const attachments = (meta.attachments ?? null);
45
- if (Array.isArray(attachments)) {
46
- return attachments.some((attachment) => {
47
- const candidate = attachment;
48
- const typeValue = typeof candidate.type === 'string' ? candidate.type.toLowerCase() : '';
49
- const urlValue = typeof candidate.url === 'string'
50
- ? candidate.url
51
- : typeof candidate.src === 'string'
52
- ? candidate.src
53
- : typeof candidate.image_url === 'string'
54
- ? candidate.image_url
55
- : typeof candidate.image_url?.url === 'string'
56
- ? candidate.image_url.url
57
- : '';
58
- return typeValue.includes('image') && urlValue.trim().length > 0;
59
- });
60
- }
61
- if (typeof meta.attachmentType === 'string' && meta.attachmentType.toLowerCase().includes('image')) {
62
- const urlCandidate = typeof meta.attachmentUrl === 'string' ? meta.attachmentUrl : '';
63
- return urlCandidate.trim().length > 0;
40
+ // 仅基于标准 Chat 语义判断是否携带图片:
41
+ // - content 为数组时查找 { type: 'image' | 'image_url' | 'input_image', ... } 块;
42
+ // - 不再依赖 metadata.attachments,也不再用纯文本关键字或剪贴板标记作为信号。
43
+ if (Array.isArray(message.content)) {
44
+ for (const part of message.content) {
45
+ if (!part || typeof part !== 'object') {
46
+ continue;
47
+ }
48
+ const record = part;
49
+ const typeValue = typeof record.type === 'string' ? record.type.toLowerCase() : '';
50
+ // For chat/standardized content, images may appear as:
51
+ // - { type: "image_url", image_url: { url } }
52
+ // - { type: "image", uri: "...", data: "...", url: "..." }
53
+ // - { type: "input_image", image_url: "data:..." }
54
+ // Treat any non-empty URL/URI/data on an image-* block as a signal.
55
+ let imageCandidate = '';
56
+ if (typeof record.image_url === 'string') {
57
+ imageCandidate = record.image_url ?? '';
58
+ }
59
+ else if (record.image_url &&
60
+ typeof record.image_url?.url === 'string') {
61
+ imageCandidate = record.image_url?.url ?? '';
62
+ }
63
+ else if (typeof record.url === 'string') {
64
+ imageCandidate = record.url ?? '';
65
+ }
66
+ else if (typeof record.uri === 'string') {
67
+ imageCandidate = record.uri ?? '';
68
+ }
69
+ else if (typeof record.data === 'string') {
70
+ imageCandidate = record.data ?? '';
71
+ }
72
+ if (typeValue.includes('image') && imageCandidate.trim().length > 0) {
73
+ return true;
74
+ }
75
+ }
64
76
  }
65
77
  return false;
66
78
  }
@@ -62,7 +62,8 @@ export class ProviderRegistry {
62
62
  processMode: profile.processMode || 'chat',
63
63
  responsesConfig: profile.responsesConfig,
64
64
  streaming: profile.streaming,
65
- maxContextTokens: profile.maxContextTokens
65
+ maxContextTokens: profile.maxContextTokens,
66
+ ...(profile.serverToolsDisabled ? { serverToolsDisabled: true } : {})
66
67
  };
67
68
  }
68
69
  }
@@ -78,11 +78,22 @@ function encodeContent(content, encoder) {
78
78
  total += encodeText(part, encoder);
79
79
  }
80
80
  else if (part && typeof part === 'object') {
81
- if (typeof part.text === 'string') {
82
- total += encodeText(part.text, encoder);
81
+ const record = part;
82
+ const typeValue = typeof record.type === 'string' ? record.type.toLowerCase() : '';
83
+ // Large binary/image payloads (data URIs, base64, etc.) should not
84
+ // dominate context estimation. For image-like blocks, only count a
85
+ // small textual placeholder instead of the full JSON/body.
86
+ if (typeValue.startsWith('image')) {
87
+ const caption = typeof record.caption === 'string' && record.caption.trim().length
88
+ ? record.caption
89
+ : '[image]';
90
+ total += encodeText(caption, encoder);
91
+ }
92
+ else if (typeof record.text === 'string') {
93
+ total += encodeText(record.text, encoder);
83
94
  }
84
95
  else {
85
- total += encodeText(JSON.stringify(part), encoder);
96
+ total += encodeText(JSON.stringify(record), encoder);
86
97
  }
87
98
  }
88
99
  }