@jsonstudio/llms 0.6.230 → 0.6.467

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 (81) hide show
  1. package/README.md +2 -0
  2. package/dist/conversion/codecs/gemini-openai-codec.js +24 -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.js +25 -28
  10. package/dist/conversion/compat/actions/iflow-web-search.d.ts +18 -0
  11. package/dist/conversion/compat/actions/iflow-web-search.js +87 -0
  12. package/dist/conversion/compat/actions/universal-shape-filter.js +11 -0
  13. package/dist/conversion/compat/profiles/chat-gemini.json +17 -0
  14. package/dist/conversion/compat/profiles/chat-glm.json +194 -184
  15. package/dist/conversion/compat/profiles/chat-iflow.json +199 -195
  16. package/dist/conversion/compat/profiles/chat-lmstudio.json +43 -43
  17. package/dist/conversion/compat/profiles/chat-qwen.json +20 -20
  18. package/dist/conversion/compat/profiles/responses-c4m.json +42 -42
  19. package/dist/conversion/config/sample-config.json +1 -1
  20. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +24 -0
  21. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +8 -0
  22. package/dist/conversion/hub/pipeline/hub-pipeline.js +32 -1
  23. package/dist/conversion/hub/pipeline/session-identifiers.d.ts +9 -0
  24. package/dist/conversion/hub/pipeline/session-identifiers.js +76 -0
  25. package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +31 -2
  26. package/dist/conversion/hub/pipeline/target-utils.js +6 -0
  27. package/dist/conversion/hub/process/chat-process.js +186 -40
  28. package/dist/conversion/hub/response/provider-response.d.ts +13 -1
  29. package/dist/conversion/hub/response/provider-response.js +84 -35
  30. package/dist/conversion/hub/response/server-side-tools.js +61 -4
  31. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +123 -3
  32. package/dist/conversion/hub/semantic-mappers/responses-mapper.js +17 -1
  33. package/dist/conversion/hub/standardized-bridge.js +14 -0
  34. package/dist/conversion/responses/responses-openai-bridge.js +110 -6
  35. package/dist/conversion/shared/anthropic-message-utils.js +133 -9
  36. package/dist/conversion/shared/bridge-message-utils.js +137 -10
  37. package/dist/conversion/shared/errors.d.ts +20 -0
  38. package/dist/conversion/shared/errors.js +28 -0
  39. package/dist/conversion/shared/responses-conversation-store.js +30 -3
  40. package/dist/conversion/shared/responses-output-builder.js +111 -8
  41. package/dist/conversion/shared/tool-filter-pipeline.js +1 -0
  42. package/dist/filters/special/request-toolcalls-stringify.d.ts +13 -0
  43. package/dist/filters/special/request-toolcalls-stringify.js +103 -3
  44. package/dist/filters/special/response-tool-text-canonicalize.d.ts +16 -0
  45. package/dist/filters/special/response-tool-text-canonicalize.js +27 -3
  46. package/dist/router/virtual-router/bootstrap.js +44 -12
  47. package/dist/router/virtual-router/classifier.js +13 -17
  48. package/dist/router/virtual-router/engine.d.ts +39 -0
  49. package/dist/router/virtual-router/engine.js +755 -55
  50. package/dist/router/virtual-router/features.js +1 -1
  51. package/dist/router/virtual-router/message-utils.js +36 -24
  52. package/dist/router/virtual-router/provider-registry.d.ts +15 -0
  53. package/dist/router/virtual-router/provider-registry.js +42 -1
  54. package/dist/router/virtual-router/routing-instructions.d.ts +34 -0
  55. package/dist/router/virtual-router/routing-instructions.js +383 -0
  56. package/dist/router/virtual-router/sticky-session-store.d.ts +3 -0
  57. package/dist/router/virtual-router/sticky-session-store.js +110 -0
  58. package/dist/router/virtual-router/token-counter.js +14 -3
  59. package/dist/router/virtual-router/tool-signals.js +0 -22
  60. package/dist/router/virtual-router/types.d.ts +80 -0
  61. package/dist/router/virtual-router/types.js +2 -1
  62. package/dist/servertool/engine.d.ts +27 -0
  63. package/dist/servertool/engine.js +101 -0
  64. package/dist/servertool/flow-types.d.ts +40 -0
  65. package/dist/servertool/flow-types.js +1 -0
  66. package/dist/servertool/handlers/vision.d.ts +1 -0
  67. package/dist/servertool/handlers/vision.js +194 -0
  68. package/dist/servertool/handlers/web-search.d.ts +1 -0
  69. package/dist/servertool/handlers/web-search.js +791 -0
  70. package/dist/servertool/orchestration-types.d.ts +33 -0
  71. package/dist/servertool/orchestration-types.js +1 -0
  72. package/dist/servertool/registry.d.ts +18 -0
  73. package/dist/servertool/registry.js +27 -0
  74. package/dist/servertool/server-side-tools.d.ts +8 -0
  75. package/dist/servertool/server-side-tools.js +208 -0
  76. package/dist/servertool/types.d.ts +94 -0
  77. package/dist/servertool/types.js +1 -0
  78. package/dist/servertool/vision-tool.d.ts +2 -0
  79. package/dist/servertool/vision-tool.js +185 -0
  80. package/dist/sse/sse-to-json/builders/response-builder.js +6 -3
  81. package/package.json +1 -1
@@ -11,6 +11,10 @@ import { writeCompatSnapshot } from '../../../compat/actions/snapshot.js';
11
11
  import { applyQwenRequestTransform, applyQwenResponseTransform } from '../../../compat/actions/qwen-transform.js';
12
12
  import { extractGlmToolMarkup } from '../../../compat/actions/glm-tool-extraction.js';
13
13
  import { applyGlmWebSearchRequestTransform } from '../../../compat/actions/glm-web-search.js';
14
+ import { applyGeminiWebSearchCompat } from '../../../compat/actions/gemini-web-search.js';
15
+ import { applyIflowWebSearchRequestTransform } from '../../../compat/actions/iflow-web-search.js';
16
+ import { applyGlmImageContentTransform } from '../../../compat/actions/glm-image-content.js';
17
+ import { applyGlmVisionPromptTransform } from '../../../compat/actions/glm-vision-prompt.js';
14
18
  const RATE_LIMIT_ERROR = 'ERR_COMPAT_RATE_LIMIT_DETECTED';
15
19
  const INTERNAL_STATE = Symbol('compat.internal_state');
16
20
  export function runRequestCompatPipeline(profileId, payload, options) {
@@ -163,6 +167,26 @@ function applyMapping(root, mapping, state) {
163
167
  replaceRoot(root, applyGlmWebSearchRequestTransform(root));
164
168
  }
165
169
  break;
170
+ case 'gemini_web_search_request':
171
+ if (state.direction === 'request') {
172
+ replaceRoot(root, applyGeminiWebSearchCompat(root, state.adapterContext));
173
+ }
174
+ break;
175
+ case 'iflow_web_search_request':
176
+ if (state.direction === 'request') {
177
+ replaceRoot(root, applyIflowWebSearchRequestTransform(root, state.adapterContext));
178
+ }
179
+ break;
180
+ case 'glm_image_content':
181
+ if (state.direction === 'request') {
182
+ replaceRoot(root, applyGlmImageContentTransform(root));
183
+ }
184
+ break;
185
+ case 'glm_vision_prompt':
186
+ if (state.direction === 'request') {
187
+ replaceRoot(root, applyGlmVisionPromptTransform(root));
188
+ }
189
+ break;
166
190
  default:
167
191
  break;
168
192
  }
@@ -100,6 +100,14 @@ export type MappingInstruction = {
100
100
  action: 'qwen_response_transform';
101
101
  } | {
102
102
  action: 'glm_web_search_request';
103
+ } | {
104
+ action: 'glm_image_content';
105
+ } | {
106
+ action: 'glm_vision_prompt';
107
+ } | {
108
+ action: 'gemini_web_search_request';
109
+ } | {
110
+ action: 'iflow_web_search_request';
103
111
  };
104
112
  export type FilterInstruction = {
105
113
  action: 'rate_limit_text';
@@ -21,6 +21,7 @@ import { runReqProcessStage2RouteSelect } from './stages/req_process/req_process
21
21
  import { runReqOutboundStage1SemanticMap } from './stages/req_outbound/req_outbound_stage1_semantic_map/index.js';
22
22
  import { runReqOutboundStage2FormatBuild } from './stages/req_outbound/req_outbound_stage2_format_build/index.js';
23
23
  import { runReqOutboundStage3Compat } from './stages/req_outbound/req_outbound_stage3_compat/index.js';
24
+ import { extractSessionIdentifiersFromMetadata } from './session-identifiers.js';
24
25
  export class HubPipeline {
25
26
  routerEngine;
26
27
  config;
@@ -120,6 +121,10 @@ export class HubPipeline {
120
121
  const responsesResume = normalizedMeta && typeof normalizedMeta.responsesResume === 'object'
121
122
  ? normalizedMeta.responsesResume
122
123
  : undefined;
124
+ const stdMetadata = workingRequest?.metadata;
125
+ const serverToolRequired = stdMetadata?.webSearchEnabled === true ||
126
+ stdMetadata?.serverToolRequired === true;
127
+ const sessionIdentifiers = extractSessionIdentifiersFromMetadata(normalized.metadata);
123
128
  const metadataInput = {
124
129
  requestId: normalized.id,
125
130
  entryEndpoint: normalized.entryEndpoint,
@@ -129,7 +134,10 @@ export class HubPipeline {
129
134
  providerProtocol: normalized.providerProtocol,
130
135
  routeHint: normalized.routeHint,
131
136
  stage: normalized.stage,
132
- responsesResume: responsesResume
137
+ responsesResume: responsesResume,
138
+ ...(serverToolRequired ? { serverToolRequired: true } : {}),
139
+ ...(sessionIdentifiers.sessionId ? { sessionId: sessionIdentifiers.sessionId } : {}),
140
+ ...(sessionIdentifiers.conversationId ? { conversationId: sessionIdentifiers.conversationId } : {})
133
141
  };
134
142
  const routing = runReqProcessStage2RouteSelect({
135
143
  routerEngine: this.routerEngine,
@@ -230,8 +238,25 @@ export class HubPipeline {
230
238
  }
231
239
  });
232
240
  }
241
+ // 为响应侧 servertool/web_search 提供一次性 Chat 请求快照,便于在 Hub 内部实现
242
+ // 第三跳(将工具结果注入消息历史后重新调用主模型)。
243
+ let capturedChatRequest;
244
+ if (normalized.processMode !== 'passthrough') {
245
+ try {
246
+ capturedChatRequest = JSON.parse(JSON.stringify({
247
+ model: workingRequest.model,
248
+ messages: workingRequest.messages,
249
+ tools: workingRequest.tools,
250
+ parameters: workingRequest.parameters
251
+ }));
252
+ }
253
+ catch {
254
+ capturedChatRequest = undefined;
255
+ }
256
+ }
233
257
  const metadata = {
234
258
  ...normalized.metadata,
259
+ ...(capturedChatRequest ? { capturedChatRequest } : {}),
235
260
  entryEndpoint: normalized.entryEndpoint,
236
261
  providerProtocol: outboundProtocol,
237
262
  stream: normalized.stream,
@@ -351,6 +376,12 @@ export class HubPipeline {
351
376
  if (typeof metadata.assignedModelId === 'string') {
352
377
  adapterContext.modelId = metadata.assignedModelId;
353
378
  }
379
+ // 将 serverToolFollowup 等 ServerTool 相关标记从 normalized.metadata 透传到 AdapterContext,
380
+ // 便于响应侧的 convertProviderResponse 正确识别“二跳/内部跳转”并跳过 servertool 编排。
381
+ if (Object.prototype.hasOwnProperty.call(metadata, 'serverToolFollowup')) {
382
+ adapterContext.serverToolFollowup = metadata
383
+ .serverToolFollowup;
384
+ }
354
385
  if (target?.compatibilityProfile && typeof target.compatibilityProfile === 'string') {
355
386
  adapterContext.compatibilityProfile = target.compatibilityProfile;
356
387
  }
@@ -0,0 +1,9 @@
1
+ export interface SessionIdentifiers {
2
+ sessionId?: string;
3
+ conversationId?: string;
4
+ }
5
+ export declare function extractSessionIdentifiersFromMetadata(metadata: Record<string, unknown> | undefined): SessionIdentifiers;
6
+ export declare function coerceClientHeaders(raw: unknown): Record<string, string> | undefined;
7
+ export declare function pickHeader(headers: Record<string, string>, candidates: string[]): string | undefined;
8
+ export declare function findHeaderValue(headers: Record<string, string>, target: string): string | undefined;
9
+ export declare function normalizeHeaderKey(value: string): string;
@@ -0,0 +1,76 @@
1
+ export function extractSessionIdentifiersFromMetadata(metadata) {
2
+ const directSession = normalizeIdentifier(metadata?.sessionId);
3
+ const directConversation = normalizeIdentifier(metadata?.conversationId);
4
+ const headers = coerceClientHeaders(metadata?.clientHeaders);
5
+ const sessionId = directSession ||
6
+ (headers ? pickHeader(headers, ['session_id', 'session-id', 'x-session-id', 'anthropic-session-id']) : undefined);
7
+ const conversationId = directConversation ||
8
+ (headers
9
+ ? pickHeader(headers, [
10
+ 'conversation_id',
11
+ 'conversation-id',
12
+ 'x-conversation-id',
13
+ 'anthropic-conversation-id',
14
+ 'openai-conversation-id'
15
+ ])
16
+ : undefined);
17
+ return {
18
+ ...(sessionId ? { sessionId } : {}),
19
+ ...(conversationId ? { conversationId } : {})
20
+ };
21
+ }
22
+ export function coerceClientHeaders(raw) {
23
+ if (!raw || typeof raw !== 'object') {
24
+ return undefined;
25
+ }
26
+ const normalized = {};
27
+ for (const [key, value] of Object.entries(raw)) {
28
+ if (typeof value === 'string' && value.trim()) {
29
+ normalized[key] = value;
30
+ }
31
+ }
32
+ return Object.keys(normalized).length ? normalized : undefined;
33
+ }
34
+ export function pickHeader(headers, candidates) {
35
+ for (const name of candidates) {
36
+ const value = findHeaderValue(headers, name);
37
+ if (value) {
38
+ return value;
39
+ }
40
+ }
41
+ return undefined;
42
+ }
43
+ export function findHeaderValue(headers, target) {
44
+ const lowered = typeof target === 'string' ? target.toLowerCase() : '';
45
+ if (!lowered) {
46
+ return undefined;
47
+ }
48
+ const normalizedTarget = normalizeHeaderKey(lowered);
49
+ for (const [key, value] of Object.entries(headers)) {
50
+ if (typeof value !== 'string') {
51
+ continue;
52
+ }
53
+ const trimmed = value.trim();
54
+ if (!trimmed) {
55
+ continue;
56
+ }
57
+ const loweredKey = key.toLowerCase();
58
+ if (loweredKey === lowered) {
59
+ return trimmed;
60
+ }
61
+ if (normalizeHeaderKey(loweredKey) === normalizedTarget) {
62
+ return trimmed;
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+ export function normalizeHeaderKey(value) {
68
+ return value.replace(/[\s_-]+/g, '');
69
+ }
70
+ function normalizeIdentifier(value) {
71
+ if (typeof value !== 'string') {
72
+ return undefined;
73
+ }
74
+ const trimmed = value.trim();
75
+ return trimmed || undefined;
76
+ }
@@ -1,5 +1,17 @@
1
1
  import { defaultSseCodecRegistry } from '../../../../../../sse/index.js';
2
2
  import { recordStage } from '../../../stages/utils.js';
3
+ import { ProviderProtocolError } from '../../../../../shared/errors.js';
4
+ function resolveProviderType(protocol) {
5
+ if (protocol === 'openai-chat')
6
+ return 'openai';
7
+ if (protocol === 'openai-responses')
8
+ return 'responses';
9
+ if (protocol === 'anthropic-messages')
10
+ return 'anthropic';
11
+ if (protocol === 'gemini-chat')
12
+ return 'gemini';
13
+ return undefined;
14
+ }
3
15
  export async function runRespInboundStage1SseDecode(options) {
4
16
  const stream = extractSseStream(options.payload);
5
17
  if (!stream) {
@@ -15,7 +27,15 @@ export async function runRespInboundStage1SseDecode(options) {
15
27
  reason: 'protocol_unsupported',
16
28
  protocol: options.providerProtocol
17
29
  });
18
- throw new Error(`[resp_inbound_stage1_sse_decode] Protocol ${options.providerProtocol} does not support SSE decoding`);
30
+ throw new ProviderProtocolError(`[resp_inbound_stage1_sse_decode] Protocol ${options.providerProtocol} does not support SSE decoding`, {
31
+ code: 'SSE_DECODE_ERROR',
32
+ protocol: options.providerProtocol,
33
+ providerType: resolveProviderType(options.providerProtocol),
34
+ details: {
35
+ phase: 'resp_inbound_stage1_sse_decode',
36
+ reason: 'protocol_unsupported'
37
+ }
38
+ });
19
39
  }
20
40
  try {
21
41
  const codec = defaultSseCodecRegistry.get(options.providerProtocol);
@@ -38,7 +58,16 @@ export async function runRespInboundStage1SseDecode(options) {
38
58
  protocol: options.providerProtocol,
39
59
  error: message
40
60
  });
41
- throw new Error(`[resp_inbound_stage1_sse_decode] Failed to decode SSE payload for protocol ${options.providerProtocol}: ${message}`);
61
+ throw new ProviderProtocolError(`[resp_inbound_stage1_sse_decode] Failed to decode SSE payload for protocol ${options.providerProtocol}: ${message}`, {
62
+ code: 'SSE_DECODE_ERROR',
63
+ protocol: options.providerProtocol,
64
+ providerType: resolveProviderType(options.providerProtocol),
65
+ details: {
66
+ phase: 'resp_inbound_stage1_sse_decode',
67
+ requestId: options.adapterContext.requestId,
68
+ message
69
+ }
70
+ });
42
71
  }
43
72
  }
44
73
  function supportsSseProtocol(protocol) {
@@ -9,6 +9,12 @@ export function applyTargetMetadata(metadata, target, routeName, originalModel)
9
9
  metadata.providerType = target.providerType;
10
10
  metadata.modelId = target.modelId;
11
11
  metadata.processMode = target.processMode || 'chat';
12
+ if (target.forceWebSearch === true) {
13
+ metadata.forceWebSearch = true;
14
+ }
15
+ if (target.forceVision === true) {
16
+ metadata.forceVision = true;
17
+ }
12
18
  if (target.responsesConfig?.toolCallIdStyle) {
13
19
  metadata.toolCallIdStyle = target.responsesConfig.toolCallIdStyle;
14
20
  }
@@ -1,5 +1,6 @@
1
1
  import { runChatRequestToolFilters } from '../../shared/tool-filter-pipeline.js';
2
2
  import { ToolGovernanceEngine } from '../tool-governance/index.js';
3
+ import { detectLastAssistantToolCategory } from '../../../router/virtual-router/tool-signals.js';
3
4
  const toolGovernanceEngine = new ToolGovernanceEngine();
4
5
  export async function runHubChatProcess(options) {
5
6
  const startTime = Date.now();
@@ -71,6 +72,14 @@ async function applyRequestToolGovernance(request, context) {
71
72
  governanceTimestamp: Date.now()
72
73
  }
73
74
  };
75
+ if (containsImageAttachment(merged.messages)) {
76
+ if (!merged.metadata) {
77
+ merged.metadata = {
78
+ originalEndpoint: request.metadata?.originalEndpoint ?? '/v1/chat/completions'
79
+ };
80
+ }
81
+ merged.metadata.hasImageAttachment = true;
82
+ }
74
83
  if (typeof inboundStreamIntent === 'boolean') {
75
84
  merged.metadata = {
76
85
  ...merged.metadata,
@@ -196,6 +205,34 @@ function castSingleTool(tool) {
196
205
  }
197
206
  };
198
207
  }
208
+ function containsImageAttachment(messages) {
209
+ if (!Array.isArray(messages)) {
210
+ return false;
211
+ }
212
+ for (const message of messages) {
213
+ if (!message || typeof message !== 'object') {
214
+ continue;
215
+ }
216
+ const content = message.content;
217
+ if (!Array.isArray(content)) {
218
+ continue;
219
+ }
220
+ for (const part of content) {
221
+ if (!part || typeof part !== 'object') {
222
+ continue;
223
+ }
224
+ const typeValue = part.type;
225
+ if (typeof typeValue !== 'string') {
226
+ continue;
227
+ }
228
+ const normalized = typeValue.toLowerCase();
229
+ if (normalized.includes('image')) {
230
+ return true;
231
+ }
232
+ }
233
+ }
234
+ return false;
235
+ }
199
236
  function castCustomTool(tool) {
200
237
  if (!isRecord(tool)) {
201
238
  return null;
@@ -277,15 +314,34 @@ function isRecord(value) {
277
314
  return !!value && typeof value === 'object' && !Array.isArray(value);
278
315
  }
279
316
  function maybeInjectWebSearchTool(request, metadata) {
317
+ // ServerTool 二/三跳(serverToolFollowup=true)不再注入 web_search 工具,
318
+ // 以避免在 web_search 流程内部形成循环命中。
319
+ if (metadata.serverToolFollowup === true) {
320
+ return request;
321
+ }
280
322
  const rawConfig = metadata.webSearch;
281
323
  if (!rawConfig || !Array.isArray(rawConfig.engines) || rawConfig.engines.length === 0) {
282
324
  return request;
283
325
  }
284
- const injectPolicy = (rawConfig.injectPolicy === 'always' || rawConfig.injectPolicy === 'selective')
326
+ const injectPolicy = rawConfig.injectPolicy === 'always' || rawConfig.injectPolicy === 'selective'
285
327
  ? rawConfig.injectPolicy
286
328
  : 'selective';
287
- if (injectPolicy === 'selective' && !detectWebSearchIntent(request)) {
288
- return request;
329
+ const intent = detectWebSearchIntent(request);
330
+ if (injectPolicy === 'selective') {
331
+ if (!intent.hasIntent) {
332
+ // 当最近一条用户消息没有明显的“联网搜索”关键词时,
333
+ // 如果上一轮 assistant 的工具调用已经属于搜索类(如 web_search),
334
+ // 则仍然视为 web_search 续写场景,强制注入 web_search 工具,
335
+ // 以便在后续路由中按 servertool 逻辑跳过不适配的 Provider(例如 serverToolsDisabled 的 crs)。
336
+ const assistantMessages = Array.isArray(request.messages)
337
+ ? request.messages.filter((msg) => msg && msg.role === 'assistant')
338
+ : [];
339
+ const lastTool = detectLastAssistantToolCategory(assistantMessages);
340
+ const hasSearchToolContext = lastTool?.category === 'search';
341
+ if (!hasSearchToolContext) {
342
+ return request;
343
+ }
344
+ }
289
345
  }
290
346
  const existingTools = Array.isArray(request.tools) ? request.tools : [];
291
347
  const hasWebSearch = existingTools.some((tool) => {
@@ -295,9 +351,35 @@ function maybeInjectWebSearchTool(request, metadata) {
295
351
  return typeof fn?.name === 'string' && fn.name.trim() === 'web_search';
296
352
  });
297
353
  if (hasWebSearch) {
298
- return request;
354
+ const nextMetadata = {
355
+ ...(request.metadata ?? {}),
356
+ webSearchEnabled: true
357
+ };
358
+ return {
359
+ ...request,
360
+ metadata: nextMetadata
361
+ };
362
+ }
363
+ let engines = rawConfig.engines.filter((engine) => typeof engine?.id === 'string' && !!engine.id.trim() && !engine.serverToolsDisabled);
364
+ // 当用户明确要求「谷歌搜索」时,只暴露 Gemini / Antigravity 类搜索后端:
365
+ // - providerKey 以 gemini-cli. 或 antigravity. 开头;
366
+ // - 或 engine id 中包含 "google"(向前兼容配置中用 id 标识 google 引擎的场景)。
367
+ if (intent.googlePreferred) {
368
+ const preferred = engines.filter((engine) => {
369
+ const id = engine.id.trim().toLowerCase();
370
+ const providerKey = (engine.providerKey || '').toLowerCase();
371
+ if (providerKey.startsWith('gemini-cli.') || providerKey.startsWith('antigravity.')) {
372
+ return true;
373
+ }
374
+ if (id.includes('google')) {
375
+ return true;
376
+ }
377
+ return false;
378
+ });
379
+ if (preferred.length > 0) {
380
+ engines = preferred;
381
+ }
299
382
  }
300
- const engines = rawConfig.engines.filter((engine) => typeof engine?.id === 'string' && !!engine.id.trim());
301
383
  if (!engines.length) {
302
384
  return request;
303
385
  }
@@ -311,19 +393,14 @@ function maybeInjectWebSearchTool(request, metadata) {
311
393
  return desc ? `${id}: ${desc}` : id;
312
394
  })
313
395
  .join('; ');
314
- const hasMultipleEngines = engineIds.length > 1;
315
396
  const parameters = {
316
397
  type: 'object',
317
398
  properties: {
318
- ...(hasMultipleEngines
319
- ? {
320
- engine: {
321
- type: 'string',
322
- enum: engineIds,
323
- description: engineDescriptions
324
- }
325
- }
326
- : {}),
399
+ engine: {
400
+ type: 'string',
401
+ enum: engineIds,
402
+ description: engineDescriptions
403
+ },
327
404
  query: {
328
405
  type: 'string',
329
406
  description: 'Search query or user question.'
@@ -340,7 +417,9 @@ function maybeInjectWebSearchTool(request, metadata) {
340
417
  description: 'Number of results to retrieve.'
341
418
  }
342
419
  },
343
- required: ['query'],
420
+ // 对于 Responses 内建 web_search,required 需要覆盖 properties 中的所有字段,
421
+ // 否则上游会报 "required is required to be supplied and to be an array including every key in properties"。
422
+ required: ['engine', 'query', 'recency', 'count'],
344
423
  additionalProperties: false
345
424
  };
346
425
  const webSearchTool = {
@@ -365,42 +444,109 @@ function maybeInjectWebSearchTool(request, metadata) {
365
444
  function detectWebSearchIntent(request) {
366
445
  const messages = Array.isArray(request.messages) ? request.messages : [];
367
446
  if (!messages.length) {
368
- return false;
447
+ return { hasIntent: false, googlePreferred: false };
369
448
  }
370
- const last = messages[messages.length - 1];
371
- if (!last || last.role !== 'user') {
372
- return false;
449
+ // 从末尾向前找到最近一条 user 消息,忽略 tool / assistant 的工具调用轮次,
450
+ // 以便在 Responses / 多轮工具调用场景下仍然根据“最近一条用户输入”判断意图。
451
+ let lastUser;
452
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
453
+ const candidate = messages[idx];
454
+ if (candidate && candidate.role === 'user') {
455
+ lastUser = candidate;
456
+ break;
457
+ }
458
+ }
459
+ if (!lastUser) {
460
+ return { hasIntent: false, googlePreferred: false };
461
+ }
462
+ // 支持多模态 content:既可能是纯文本字符串,也可能是带 image_url 的分段数组。
463
+ let content = '';
464
+ if (typeof lastUser.content === 'string') {
465
+ content = lastUser.content;
466
+ }
467
+ else if (Array.isArray(lastUser.content)) {
468
+ const parts = lastUser.content;
469
+ const texts = [];
470
+ for (const part of parts) {
471
+ if (typeof part === 'string') {
472
+ texts.push(part);
473
+ }
474
+ else if (part && typeof part === 'object') {
475
+ const maybeText = part.text;
476
+ if (typeof maybeText === 'string' && maybeText.trim()) {
477
+ texts.push(maybeText);
478
+ }
479
+ }
480
+ }
481
+ content = texts.join('\n');
373
482
  }
374
- const content = typeof last.content === 'string' ? last.content : '';
375
483
  if (!content) {
376
- return false;
484
+ return { hasIntent: false, googlePreferred: false };
485
+ }
486
+ // Hard 100% keywords (中文):明确说明“谷歌搜索 / 谷歌一下 / 百度一下”均视为搜索意图。
487
+ // 其中“谷歌搜索 / 谷歌一下”会偏向 Google/Gemini 搜索后端。
488
+ const zh = content;
489
+ const hasGoogleExplicit = zh.includes('谷歌搜索') ||
490
+ zh.includes('谷歌一下');
491
+ const hasBaiduExplicit = zh.includes('百度一下');
492
+ if (hasGoogleExplicit || hasBaiduExplicit) {
493
+ // 谷歌 / 百度关键字都会优先尝试走“谷歌搜索”引擎;
494
+ // 只有在 Virtual Router 未配置任何谷歌相关 engine 时,才回退为普通联网搜索。
495
+ return {
496
+ hasIntent: true,
497
+ googlePreferred: true
498
+ };
377
499
  }
500
+ // English intent: simple substring match on lowercased text.
378
501
  const text = content.toLowerCase();
379
- const keywords = [
380
- // English
502
+ // 1) Direct patterns like "web search" / "internet search" / "/search".
503
+ const englishDirect = [
381
504
  'web search',
382
505
  'web_search',
383
506
  'websearch',
384
507
  'internet search',
385
508
  'search the web',
386
- 'online search',
387
- 'search online',
388
- 'search on the internet',
389
- 'search the internet',
390
509
  'web-search',
391
- 'online-search',
392
510
  'internet-search',
393
- // Chinese
394
- '联网搜索',
395
- '网络搜索',
396
- '上网搜索',
397
- '网上搜索',
398
- '网上查',
399
- '网上查找',
400
- '上网查',
401
- '上网搜',
402
- // Command-style
403
511
  '/search'
404
512
  ];
405
- return keywords.some((keyword) => text.includes(keyword.toLowerCase()));
513
+ if (englishDirect.some((keyword) => text.includes(keyword))) {
514
+ return { hasIntent: true, googlePreferred: text.includes('google') };
515
+ }
516
+ // 2) Verb + noun combinations, similar to the Chinese rule:
517
+ // - verb: search / find / look up / look for / google
518
+ // - noun: web / internet / online / news / information / info / report / reports / article / articles
519
+ const verbTokensEn = ['search', 'find', 'look up', 'look for', 'google'];
520
+ const nounTokensEn = [
521
+ 'web',
522
+ 'internet',
523
+ 'online',
524
+ 'news',
525
+ 'information',
526
+ 'info',
527
+ 'report',
528
+ 'reports',
529
+ 'article',
530
+ 'articles'
531
+ ];
532
+ const hasVerbEn = verbTokensEn.some((token) => text.includes(token));
533
+ const hasNounEn = nounTokensEn.some((token) => text.includes(token));
534
+ if (hasVerbEn && hasNounEn) {
535
+ return { hasIntent: true, googlePreferred: text.includes('google') };
536
+ }
537
+ // 中文规则:
538
+ // 1. 只要文本中包含“上网”,直接命中(例如“帮我上网看看今天的新闻”)。
539
+ // 2. 否则,如果同时包含「搜索/查找/搜」中的任意一个动词 + 「网络/联网/新闻/信息/报道」中的任意一个名词,也判定为联网搜索意图。
540
+ const chineseText = content; // 中文大小写不敏感,这里直接用原文。
541
+ if (chineseText.includes('上网')) {
542
+ return { hasIntent: true, googlePreferred: false };
543
+ }
544
+ const verbTokens = ['搜索', '查找', '搜'];
545
+ const nounTokens = ['网络', '联网', '新闻', '信息', '报道'];
546
+ const hasVerb = verbTokens.some((token) => chineseText.includes(token));
547
+ const hasNoun = nounTokens.some((token) => chineseText.includes(token));
548
+ if (hasVerb && hasNoun) {
549
+ return { hasIntent: true, googlePreferred: false };
550
+ }
551
+ return { hasIntent: false, googlePreferred: false };
406
552
  }
@@ -2,7 +2,7 @@ import { Readable } from 'node:stream';
2
2
  import type { AdapterContext } from '../types/chat-envelope.js';
3
3
  import type { JsonObject } from '../types/json.js';
4
4
  import type { StageRecorder } from '../format-adapters/index.js';
5
- import type { ProviderInvoker } from './server-side-tools.js';
5
+ import type { ProviderInvoker } from '../../../servertool/types.js';
6
6
  type ProviderProtocol = 'openai-chat' | 'openai-responses' | 'anthropic-messages' | 'gemini-chat';
7
7
  export interface ProviderResponseConversionOptions {
8
8
  providerProtocol: ProviderProtocol;
@@ -12,6 +12,18 @@ export interface ProviderResponseConversionOptions {
12
12
  wantsStream: boolean;
13
13
  stageRecorder?: StageRecorder;
14
14
  providerInvoker?: ProviderInvoker;
15
+ /**
16
+ * 可选:由 Host 注入的二次请求入口。Server-side 工具在需要发起
17
+ * followup 请求(例如 web_search 二跳)时,可以通过该回调将构造
18
+ * 好的请求体交给 Host,由 Host 走完整 HubPipeline + VirtualRouter
19
+ * 再返回最终客户端响应形状。
20
+ */
21
+ reenterPipeline?: (options: {
22
+ entryEndpoint: string;
23
+ requestId: string;
24
+ body: JsonObject;
25
+ metadata?: JsonObject;
26
+ }) => Promise<ProviderResponseConversionResult>;
15
27
  }
16
28
  export interface ProviderResponseConversionResult {
17
29
  body?: JsonObject;