@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.
- package/README.md +2 -0
- package/dist/conversion/codecs/gemini-openai-codec.js +24 -2
- package/dist/conversion/compat/actions/gemini-web-search.d.ts +17 -0
- package/dist/conversion/compat/actions/gemini-web-search.js +68 -0
- package/dist/conversion/compat/actions/glm-image-content.d.ts +2 -0
- package/dist/conversion/compat/actions/glm-image-content.js +83 -0
- package/dist/conversion/compat/actions/glm-vision-prompt.d.ts +11 -0
- package/dist/conversion/compat/actions/glm-vision-prompt.js +177 -0
- package/dist/conversion/compat/actions/glm-web-search.js +25 -28
- package/dist/conversion/compat/actions/iflow-web-search.d.ts +18 -0
- package/dist/conversion/compat/actions/iflow-web-search.js +87 -0
- package/dist/conversion/compat/actions/universal-shape-filter.js +11 -0
- package/dist/conversion/compat/profiles/chat-gemini.json +17 -0
- package/dist/conversion/compat/profiles/chat-glm.json +194 -184
- package/dist/conversion/compat/profiles/chat-iflow.json +199 -195
- package/dist/conversion/compat/profiles/chat-lmstudio.json +43 -43
- package/dist/conversion/compat/profiles/chat-qwen.json +20 -20
- package/dist/conversion/compat/profiles/responses-c4m.json +42 -42
- package/dist/conversion/config/sample-config.json +1 -1
- package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +24 -0
- package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +8 -0
- package/dist/conversion/hub/pipeline/hub-pipeline.js +32 -1
- package/dist/conversion/hub/pipeline/session-identifiers.d.ts +9 -0
- package/dist/conversion/hub/pipeline/session-identifiers.js +76 -0
- package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +31 -2
- package/dist/conversion/hub/pipeline/target-utils.js +6 -0
- package/dist/conversion/hub/process/chat-process.js +186 -40
- package/dist/conversion/hub/response/provider-response.d.ts +13 -1
- package/dist/conversion/hub/response/provider-response.js +84 -35
- package/dist/conversion/hub/response/server-side-tools.js +61 -4
- package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +123 -3
- package/dist/conversion/hub/semantic-mappers/responses-mapper.js +17 -1
- package/dist/conversion/hub/standardized-bridge.js +14 -0
- package/dist/conversion/responses/responses-openai-bridge.js +110 -6
- package/dist/conversion/shared/anthropic-message-utils.js +133 -9
- package/dist/conversion/shared/bridge-message-utils.js +137 -10
- package/dist/conversion/shared/errors.d.ts +20 -0
- package/dist/conversion/shared/errors.js +28 -0
- package/dist/conversion/shared/responses-conversation-store.js +30 -3
- package/dist/conversion/shared/responses-output-builder.js +111 -8
- package/dist/conversion/shared/tool-filter-pipeline.js +1 -0
- package/dist/filters/special/request-toolcalls-stringify.d.ts +13 -0
- package/dist/filters/special/request-toolcalls-stringify.js +103 -3
- package/dist/filters/special/response-tool-text-canonicalize.d.ts +16 -0
- package/dist/filters/special/response-tool-text-canonicalize.js +27 -3
- package/dist/router/virtual-router/bootstrap.js +44 -12
- package/dist/router/virtual-router/classifier.js +13 -17
- package/dist/router/virtual-router/engine.d.ts +39 -0
- package/dist/router/virtual-router/engine.js +755 -55
- package/dist/router/virtual-router/features.js +1 -1
- package/dist/router/virtual-router/message-utils.js +36 -24
- package/dist/router/virtual-router/provider-registry.d.ts +15 -0
- package/dist/router/virtual-router/provider-registry.js +42 -1
- package/dist/router/virtual-router/routing-instructions.d.ts +34 -0
- package/dist/router/virtual-router/routing-instructions.js +383 -0
- package/dist/router/virtual-router/sticky-session-store.d.ts +3 -0
- package/dist/router/virtual-router/sticky-session-store.js +110 -0
- package/dist/router/virtual-router/token-counter.js +14 -3
- package/dist/router/virtual-router/tool-signals.js +0 -22
- package/dist/router/virtual-router/types.d.ts +80 -0
- package/dist/router/virtual-router/types.js +2 -1
- package/dist/servertool/engine.d.ts +27 -0
- package/dist/servertool/engine.js +101 -0
- package/dist/servertool/flow-types.d.ts +40 -0
- package/dist/servertool/flow-types.js +1 -0
- package/dist/servertool/handlers/vision.d.ts +1 -0
- package/dist/servertool/handlers/vision.js +194 -0
- package/dist/servertool/handlers/web-search.d.ts +1 -0
- package/dist/servertool/handlers/web-search.js +791 -0
- package/dist/servertool/orchestration-types.d.ts +33 -0
- package/dist/servertool/orchestration-types.js +1 -0
- package/dist/servertool/registry.d.ts +18 -0
- package/dist/servertool/registry.js +27 -0
- package/dist/servertool/server-side-tools.d.ts +8 -0
- package/dist/servertool/server-side-tools.js +208 -0
- package/dist/servertool/types.d.ts +94 -0
- package/dist/servertool/types.js +1 -0
- package/dist/servertool/vision-tool.d.ts +2 -0
- package/dist/servertool/vision-tool.js +185 -0
- package/dist/sse/sse-to-json/builders/response-builder.js +6 -3
- 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
|
+
}
|
package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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 =
|
|
326
|
+
const injectPolicy = rawConfig.injectPolicy === 'always' || rawConfig.injectPolicy === 'selective'
|
|
285
327
|
? rawConfig.injectPolicy
|
|
286
328
|
: 'selective';
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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 '
|
|
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;
|