@jsonstudio/llms 0.6.203 → 0.6.230

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 (32) hide show
  1. package/dist/conversion/codecs/gemini-openai-codec.js +128 -4
  2. package/dist/conversion/compat/actions/glm-web-search.d.ts +2 -0
  3. package/dist/conversion/compat/actions/glm-web-search.js +66 -0
  4. package/dist/conversion/compat/profiles/chat-glm.json +4 -1
  5. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +6 -0
  6. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +2 -0
  7. package/dist/conversion/hub/pipeline/hub-pipeline.js +11 -3
  8. package/dist/conversion/hub/process/chat-process.js +131 -1
  9. package/dist/conversion/hub/response/provider-response.d.ts +22 -0
  10. package/dist/conversion/hub/response/provider-response.js +12 -1
  11. package/dist/conversion/hub/response/server-side-tools.d.ts +26 -0
  12. package/dist/conversion/hub/response/server-side-tools.js +326 -0
  13. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +118 -11
  14. package/dist/conversion/hub/types/standardized.d.ts +1 -0
  15. package/dist/conversion/responses/responses-openai-bridge.js +49 -3
  16. package/dist/conversion/shared/snapshot-utils.js +17 -47
  17. package/dist/conversion/shared/tool-mapping.js +25 -2
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/router/virtual-router/bootstrap.js +273 -40
  21. package/dist/router/virtual-router/context-advisor.d.ts +0 -2
  22. package/dist/router/virtual-router/context-advisor.js +0 -12
  23. package/dist/router/virtual-router/engine.d.ts +8 -2
  24. package/dist/router/virtual-router/engine.js +176 -81
  25. package/dist/router/virtual-router/types.d.ts +21 -2
  26. package/dist/sse/json-to-sse/event-generators/responses.js +15 -3
  27. package/dist/sse/sse-to-json/gemini-sse-to-json-converter.js +27 -1
  28. package/dist/sse/types/gemini-types.d.ts +20 -1
  29. package/dist/sse/types/responses-types.js +1 -1
  30. package/dist/telemetry/stats-center.d.ts +73 -0
  31. package/dist/telemetry/stats-center.js +280 -0
  32. package/package.json +1 -1
@@ -0,0 +1,326 @@
1
+ import { buildOpenAIChatFromGeminiResponse } from '../../codecs/gemini-openai-codec.js';
2
+ function asObject(value) {
3
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
4
+ }
5
+ function getArray(value) {
6
+ return Array.isArray(value) ? value : [];
7
+ }
8
+ function extractToolCalls(chatResponse) {
9
+ const choices = getArray(chatResponse.choices);
10
+ const calls = [];
11
+ for (const choice of choices) {
12
+ const choiceObj = asObject(choice);
13
+ if (!choiceObj)
14
+ continue;
15
+ const message = asObject(choiceObj.message);
16
+ if (!message)
17
+ continue;
18
+ const toolCalls = getArray(message.tool_calls);
19
+ for (const raw of toolCalls) {
20
+ const tc = asObject(raw);
21
+ if (!tc)
22
+ continue;
23
+ const id = typeof tc.id === 'string' && tc.id.trim() ? tc.id.trim() : '';
24
+ const fn = asObject(tc.function);
25
+ const name = fn && typeof fn.name === 'string' && fn.name.trim() ? fn.name.trim() : '';
26
+ const args = fn && typeof fn.arguments === 'string' ? fn.arguments : '';
27
+ if (!id || !name)
28
+ continue;
29
+ calls.push({ id, name, arguments: args });
30
+ }
31
+ }
32
+ return calls;
33
+ }
34
+ function extractTextFromChatLike(payload) {
35
+ const choices = getArray(payload.choices);
36
+ if (!choices.length)
37
+ return '';
38
+ const first = asObject(choices[0]);
39
+ if (!first)
40
+ return '';
41
+ const message = asObject(first.message);
42
+ if (!message)
43
+ return '';
44
+ const content = message.content;
45
+ if (typeof content === 'string')
46
+ return content;
47
+ const parts = getArray(content);
48
+ const texts = [];
49
+ for (const part of parts) {
50
+ if (typeof part === 'string') {
51
+ texts.push(part);
52
+ }
53
+ else if (part && typeof part === 'object') {
54
+ const record = part;
55
+ if (typeof record.text === 'string') {
56
+ texts.push(record.text);
57
+ }
58
+ }
59
+ }
60
+ return texts.join('\n').trim();
61
+ }
62
+ function getWebSearchConfig(ctx) {
63
+ const raw = ctx.webSearch;
64
+ const record = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
65
+ if (!record)
66
+ return undefined;
67
+ const enginesRaw = Array.isArray(record.engines) ? record.engines : [];
68
+ const engines = [];
69
+ for (const entry of enginesRaw) {
70
+ const obj = entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : null;
71
+ if (!obj)
72
+ continue;
73
+ const id = typeof obj.id === 'string' && obj.id.trim() ? obj.id.trim() : undefined;
74
+ const providerKey = typeof obj.providerKey === 'string' && obj.providerKey.trim()
75
+ ? obj.providerKey.trim()
76
+ : undefined;
77
+ if (!id || !providerKey)
78
+ continue;
79
+ engines.push({
80
+ id,
81
+ providerKey,
82
+ description: typeof obj.description === 'string' && obj.description.trim() ? obj.description.trim() : undefined,
83
+ default: obj.default === true
84
+ });
85
+ }
86
+ if (!engines.length) {
87
+ return undefined;
88
+ }
89
+ const config = { engines };
90
+ if (typeof record.injectPolicy === 'string') {
91
+ const val = String(record.injectPolicy).trim().toLowerCase();
92
+ if (val === 'always' || val === 'selective') {
93
+ config.injectPolicy = val;
94
+ }
95
+ }
96
+ return config;
97
+ }
98
+ function resolveWebSearchEngine(config, engineId) {
99
+ const trimmedId = engineId && typeof engineId === 'string' && engineId.trim() ? engineId.trim() : undefined;
100
+ if (trimmedId) {
101
+ const byId = config.engines.find((e) => e.id === trimmedId);
102
+ if (byId) {
103
+ return byId;
104
+ }
105
+ }
106
+ const byDefault = config.engines.find((e) => e.default);
107
+ if (byDefault) {
108
+ return byDefault;
109
+ }
110
+ if (config.engines.length === 1) {
111
+ return config.engines[0];
112
+ }
113
+ return undefined;
114
+ }
115
+ function isGeminiWebSearchEngine(engine) {
116
+ const key = engine.providerKey.toLowerCase();
117
+ return key.startsWith('gemini-cli.') || key.startsWith('antigravity.');
118
+ }
119
+ function logServerToolWebSearch(engine, requestId, query) {
120
+ const providerAlias = engine.providerKey.split('.')[0] || engine.providerKey;
121
+ const backendLabel = `${providerAlias}:${engine.id}`;
122
+ const prefix = `[server-tool][web_search][${backendLabel}]`;
123
+ const line = `${prefix} requestId=${requestId} query=${JSON.stringify(query)}`;
124
+ // 深蓝色输出
125
+ // eslint-disable-next-line no-console
126
+ console.log(`\x1b[38;5;27m${line}\x1b[0m`);
127
+ }
128
+ function resolveEnvServerSideToolsEnabled() {
129
+ const raw = (process.env.ROUTECODEX_SERVER_SIDE_TOOLS || process.env.RCC_SERVER_SIDE_TOOLS || '').trim().toLowerCase();
130
+ if (!raw)
131
+ return false;
132
+ if (raw === '1' || raw === 'true' || raw === 'yes')
133
+ return true;
134
+ if (raw === 'web_search' || raw === 'websearch')
135
+ return true;
136
+ return false;
137
+ }
138
+ export async function runServerSideToolEngine(options) {
139
+ const base = asObject(options.chatResponse);
140
+ if (!base) {
141
+ return { mode: 'passthrough', finalChatResponse: options.chatResponse };
142
+ }
143
+ // 内建 OpenAI Responses `/v1/responses` 已经支持 server-side web_search。
144
+ // 仅当“入口端点为 /v1/responses 且 providerProtocol 也是 openai-responses”时保持透传,
145
+ // 避免对 Responses→Responses 的链路重复执行搜索回环;
146
+ // 其它场景(例如 /v1/responses → gemini/glm 后端)仍允许 server-side web_search 生效。
147
+ const entry = (options.entryEndpoint || '').toLowerCase();
148
+ if (options.providerProtocol === 'openai-responses' && entry.includes('/v1/responses')) {
149
+ return { mode: 'passthrough', finalChatResponse: base };
150
+ }
151
+ // Feature flag: keep behaviour fully backwards-compatible unless explicitly enabled.
152
+ const toolsEnabled = resolveEnvServerSideToolsEnabled();
153
+ const toolCalls = extractToolCalls(base);
154
+ const webSearchCall = toolCalls.find((tc) => tc.name === 'web_search');
155
+ if (!toolsEnabled || !webSearchCall || !options.providerInvoker) {
156
+ return { mode: 'passthrough', finalChatResponse: base };
157
+ }
158
+ const webSearchConfig = getWebSearchConfig(options.adapterContext);
159
+ if (!webSearchConfig) {
160
+ return { mode: 'passthrough', finalChatResponse: base };
161
+ }
162
+ let parsedArgs = {};
163
+ if (webSearchCall.arguments && typeof webSearchCall.arguments === 'string') {
164
+ try {
165
+ parsedArgs = JSON.parse(webSearchCall.arguments);
166
+ }
167
+ catch {
168
+ parsedArgs = {};
169
+ }
170
+ }
171
+ const query = typeof parsedArgs?.query === 'string' && parsedArgs.query.trim()
172
+ ? parsedArgs.query.trim()
173
+ : undefined;
174
+ const engineId = typeof parsedArgs?.engine === 'string' && parsedArgs.engine.trim()
175
+ ? parsedArgs.engine.trim()
176
+ : undefined;
177
+ const recency = typeof parsedArgs?.recency === 'string' && parsedArgs.recency.trim()
178
+ ? parsedArgs.recency.trim()
179
+ : undefined;
180
+ const count = typeof parsedArgs?.count === 'number' && Number.isFinite(parsedArgs.count)
181
+ ? Math.floor(parsedArgs.count)
182
+ : undefined;
183
+ if (!query) {
184
+ return { mode: 'passthrough', finalChatResponse: base };
185
+ }
186
+ const engine = resolveWebSearchEngine(webSearchConfig, engineId);
187
+ if (!engine) {
188
+ return { mode: 'passthrough', finalChatResponse: base };
189
+ }
190
+ const providerInvoker = options.providerInvoker;
191
+ // 1) Call search backend (secondary request)
192
+ let searchSummary = '';
193
+ try {
194
+ logServerToolWebSearch(engine, options.requestId, query);
195
+ if (isGeminiWebSearchEngine(engine)) {
196
+ const geminiPayload = {
197
+ model: engine.id,
198
+ contents: [
199
+ {
200
+ role: 'user',
201
+ parts: [
202
+ {
203
+ text: query
204
+ }
205
+ ]
206
+ }
207
+ ],
208
+ // Cloud Code search models treat googleSearch as the web search tool.
209
+ tools: [
210
+ {
211
+ googleSearch: {}
212
+ }
213
+ ]
214
+ };
215
+ const backend = await providerInvoker({
216
+ providerKey: engine.providerKey,
217
+ providerType: undefined,
218
+ modelId: engine.id,
219
+ providerProtocol: 'gemini-chat',
220
+ payload: geminiPayload,
221
+ entryEndpoint: '/v1/models/gemini:generateContent',
222
+ requestId: `${options.requestId}:web_search:${engine.id}`
223
+ });
224
+ const backendResponse = asObject(backend.providerResponse);
225
+ if (backendResponse) {
226
+ const chatLike = asObject(buildOpenAIChatFromGeminiResponse(backendResponse));
227
+ if (chatLike) {
228
+ searchSummary = extractTextFromChatLike(chatLike);
229
+ }
230
+ }
231
+ }
232
+ else {
233
+ const backendPayload = {
234
+ model: engine.providerKey,
235
+ messages: [
236
+ {
237
+ role: 'system',
238
+ content: 'You are a web search engine. Answer with up-to-date information based on the open internet.'
239
+ },
240
+ {
241
+ role: 'user',
242
+ content: query
243
+ }
244
+ ],
245
+ stream: false,
246
+ web_search: {
247
+ query,
248
+ ...(recency ? { recency } : {}),
249
+ ...(typeof count === 'number' ? { count } : {}),
250
+ engine: engine.id
251
+ }
252
+ };
253
+ const backend = await providerInvoker({
254
+ providerKey: engine.providerKey,
255
+ providerType: undefined,
256
+ modelId: undefined,
257
+ providerProtocol: options.providerProtocol,
258
+ payload: backendPayload,
259
+ entryEndpoint: '/v1/chat/completions',
260
+ requestId: `${options.requestId}:web_search:${engine.id}`
261
+ });
262
+ const backendResponse = asObject(backend.providerResponse);
263
+ if (backendResponse) {
264
+ searchSummary = extractTextFromChatLike(backendResponse);
265
+ }
266
+ }
267
+ }
268
+ catch {
269
+ // fall back to passthrough if backend fails
270
+ return { mode: 'passthrough', finalChatResponse: base };
271
+ }
272
+ if (!searchSummary) {
273
+ return { mode: 'passthrough', finalChatResponse: base };
274
+ }
275
+ // 2) Call main provider again with synthesized prompt (third request)
276
+ const ctxTarget = options.adapterContext.target;
277
+ const target = ctxTarget && typeof ctxTarget === 'object' && !Array.isArray(ctxTarget)
278
+ ? ctxTarget
279
+ : undefined;
280
+ const mainProviderKey = typeof target?.providerKey === 'string' && target.providerKey.trim()
281
+ ? target.providerKey.trim()
282
+ : undefined;
283
+ const mainModelId = (typeof base.model === 'string' && base.model.trim()
284
+ ? base.model.trim()
285
+ : typeof target?.modelId === 'string' && target.modelId.trim()
286
+ ? target.modelId.trim()
287
+ : undefined) ?? 'gpt-4o-mini';
288
+ if (!mainProviderKey) {
289
+ return { mode: 'passthrough', finalChatResponse: base };
290
+ }
291
+ let finalResponse = base;
292
+ try {
293
+ const followupPayload = {
294
+ model: mainModelId,
295
+ messages: [
296
+ {
297
+ role: 'system',
298
+ content: 'You are an assistant that answers using the provided web_search results. Use them as the primary source.'
299
+ },
300
+ {
301
+ role: 'user',
302
+ content: `User question: ${query}\n\nWeb search results:\n${searchSummary}`
303
+ }
304
+ ],
305
+ stream: false
306
+ };
307
+ const followup = await providerInvoker({
308
+ providerKey: mainProviderKey,
309
+ providerType: undefined,
310
+ modelId: mainModelId,
311
+ providerProtocol: options.providerProtocol,
312
+ payload: followupPayload,
313
+ entryEndpoint: '/v1/chat/completions',
314
+ requestId: `${options.requestId}:web_search_followup`
315
+ });
316
+ const followupResponse = asObject(followup.providerResponse);
317
+ if (followupResponse) {
318
+ finalResponse = followupResponse;
319
+ }
320
+ }
321
+ catch {
322
+ // If follow-up fails, keep original base response to avoid breaking clients.
323
+ finalResponse = base;
324
+ }
325
+ return { mode: 'web_search_flow', finalChatResponse: finalResponse };
326
+ }
@@ -17,6 +17,31 @@ const GENERATION_CONFIG_KEYS = [
17
17
  ];
18
18
  const PASSTHROUGH_METADATA_PREFIX = 'rcc_passthrough_';
19
19
  const PASSTHROUGH_PARAMETERS = ['tool_choice'];
20
+ const DUMMY_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
21
+ function coerceThoughtSignature(value) {
22
+ if (typeof value === 'string' && value.trim().length) {
23
+ return value.trim();
24
+ }
25
+ return undefined;
26
+ }
27
+ function extractThoughtSignatureFromToolCall(tc) {
28
+ if (!tc || typeof tc !== 'object') {
29
+ return undefined;
30
+ }
31
+ const node = tc;
32
+ const direct = coerceThoughtSignature(node.thought_signature ?? node.thoughtSignature);
33
+ if (direct) {
34
+ return direct;
35
+ }
36
+ const extra = node.extra_content ?? node.extraContent;
37
+ if (extra && typeof extra === 'object') {
38
+ const googleNode = extra.google ?? extra.Google;
39
+ if (googleNode && typeof googleNode === 'object') {
40
+ return coerceThoughtSignature(googleNode.thought_signature ?? googleNode.thoughtSignature);
41
+ }
42
+ }
43
+ return undefined;
44
+ }
20
45
  function normalizeToolOutputs(messages, missing) {
21
46
  const outputs = [];
22
47
  messages.forEach((msg, index) => {
@@ -35,6 +60,37 @@ function normalizeToolOutputs(messages, missing) {
35
60
  });
36
61
  return outputs.length ? outputs : undefined;
37
62
  }
63
+ function synthesizeToolOutputsFromMessages(messages) {
64
+ if (!Array.isArray(messages)) {
65
+ return [];
66
+ }
67
+ const outputs = [];
68
+ for (const message of messages) {
69
+ if (!message || typeof message !== 'object')
70
+ continue;
71
+ if (message.role !== 'assistant')
72
+ continue;
73
+ const toolCalls = Array.isArray(message.tool_calls)
74
+ ? message.tool_calls
75
+ : [];
76
+ for (const call of toolCalls) {
77
+ const callId = typeof call.id === 'string' ? call.id : undefined;
78
+ if (!callId) {
79
+ continue;
80
+ }
81
+ const existing = outputs.find((entry) => entry.tool_call_id === callId);
82
+ if (existing) {
83
+ continue;
84
+ }
85
+ outputs.push({
86
+ tool_call_id: callId,
87
+ content: '',
88
+ name: (call.function && call.function.name) || undefined
89
+ });
90
+ }
91
+ }
92
+ return outputs;
93
+ }
38
94
  function normalizeToolContent(value) {
39
95
  if (typeof value === 'string')
40
96
  return value;
@@ -47,6 +103,30 @@ function normalizeToolContent(value) {
47
103
  return String(value ?? '');
48
104
  }
49
105
  }
106
+ function convertToolMessageToOutput(message) {
107
+ const rawId = (message.tool_call_id ?? message.id);
108
+ const callId = typeof rawId === 'string' && rawId.trim().length ? rawId.trim() : undefined;
109
+ if (!callId) {
110
+ return null;
111
+ }
112
+ return {
113
+ tool_call_id: callId,
114
+ content: normalizeToolContent(message.content),
115
+ name: typeof message.name === 'string' ? message.name : undefined
116
+ };
117
+ }
118
+ function buildFunctionResponseEntry(output) {
119
+ const parsedPayload = safeParseJson(output.content);
120
+ const normalizedPayload = ensureFunctionResponsePayload(cloneAsJsonValue(parsedPayload));
121
+ const part = {
122
+ functionResponse: {
123
+ name: output.name || 'tool',
124
+ id: output.tool_call_id,
125
+ response: normalizedPayload
126
+ }
127
+ };
128
+ return { role: 'user', parts: [part] };
129
+ }
50
130
  function collectSystemSegments(systemInstruction) {
51
131
  if (!systemInstruction)
52
132
  return [];
@@ -93,13 +173,20 @@ function collectParameters(payload) {
93
173
  }
94
174
  function buildGeminiRequestFromChat(chat, metadata) {
95
175
  const contents = [];
176
+ const emittedToolOutputs = new Set();
96
177
  for (const message of chat.messages) {
97
178
  if (!message || typeof message !== 'object')
98
179
  continue;
99
180
  if (message.role === 'system')
100
181
  continue;
101
- if (message.role === 'tool')
182
+ if (message.role === 'tool') {
183
+ const toolOutput = convertToolMessageToOutput(message);
184
+ if (toolOutput) {
185
+ contents.push(buildFunctionResponseEntry(toolOutput));
186
+ emittedToolOutputs.add(toolOutput.tool_call_id);
187
+ }
102
188
  continue;
189
+ }
103
190
  const entry = {
104
191
  role: mapChatRoleToGemini(message.role),
105
192
  parts: []
@@ -131,24 +218,36 @@ function buildGeminiRequestFromChat(chat, metadata) {
131
218
  if (typeof tc.id === 'string') {
132
219
  part.functionCall.id = tc.id;
133
220
  }
221
+ const thoughtSignature = extractThoughtSignatureFromToolCall(tc) ?? DUMMY_THOUGHT_SIGNATURE;
222
+ if (thoughtSignature) {
223
+ part.thoughtSignature = thoughtSignature;
224
+ }
134
225
  entry.parts.push(part);
135
226
  }
136
227
  if (entry.parts.length) {
137
228
  contents.push(entry);
138
229
  }
139
230
  }
231
+ const toolOutputMap = new Map();
140
232
  if (Array.isArray(chat.toolOutputs)) {
141
- for (const output of chat.toolOutputs) {
142
- const response = cloneAsJsonValue(safeParseJson(output.content));
143
- const part = {
144
- functionResponse: {
145
- name: output.name || 'tool',
146
- id: output.tool_call_id,
147
- response
148
- }
149
- };
150
- contents.push({ role: 'user', parts: [part] });
233
+ for (const entry of chat.toolOutputs) {
234
+ if (entry && typeof entry.tool_call_id === 'string' && entry.tool_call_id.trim().length) {
235
+ toolOutputMap.set(entry.tool_call_id.trim(), entry);
236
+ }
237
+ }
238
+ }
239
+ if (toolOutputMap.size === 0) {
240
+ const syntheticOutputs = synthesizeToolOutputsFromMessages(chat.messages);
241
+ for (const output of syntheticOutputs) {
242
+ toolOutputMap.set(output.tool_call_id, output);
243
+ }
244
+ }
245
+ for (const output of toolOutputMap.values()) {
246
+ if (emittedToolOutputs.has(output.tool_call_id)) {
247
+ continue;
151
248
  }
249
+ contents.push(buildFunctionResponseEntry(output));
250
+ emittedToolOutputs.add(output.tool_call_id);
152
251
  }
153
252
  const request = {
154
253
  model: chat.parameters?.model || 'models/gemini-pro',
@@ -235,6 +334,14 @@ function safeParseJson(value) {
235
334
  return value;
236
335
  }
237
336
  }
337
+ function ensureFunctionResponsePayload(value) {
338
+ if (value && typeof value === 'object') {
339
+ return value;
340
+ }
341
+ return {
342
+ result: value === undefined ? null : value
343
+ };
344
+ }
238
345
  function cloneAsJsonValue(value) {
239
346
  try {
240
347
  return JSON.parse(JSON.stringify(value ?? null));
@@ -65,6 +65,7 @@ export interface StandardizedMetadata {
65
65
  providerType?: string;
66
66
  processMode?: 'chat' | 'passthrough';
67
67
  routeHint?: string;
68
+ webSearchEnabled?: boolean;
68
69
  [key: string]: unknown;
69
70
  }
70
71
  export interface StandardizedRequest {
@@ -146,17 +146,63 @@ function normalizeBridgeHistory(seed) {
146
146
  originalSystemMessages: systemMessages
147
147
  };
148
148
  }
149
+ function mergeResponsesTools(originalTools, fromChat) {
150
+ const result = [];
151
+ const byKey = new Map();
152
+ const norm = (value) => {
153
+ if (typeof value !== 'string')
154
+ return undefined;
155
+ const trimmed = value.trim();
156
+ return trimmed.length ? trimmed.toLowerCase() : undefined;
157
+ };
158
+ const register = (tool) => {
159
+ const fn = tool.function ?? tool.function;
160
+ const baseName = norm(fn?.name ?? tool.name);
161
+ const typeName = norm(tool.type);
162
+ const key = baseName || typeName;
163
+ if (!key || byKey.has(key))
164
+ return;
165
+ byKey.set(key, tool);
166
+ result.push(tool);
167
+ };
168
+ if (Array.isArray(fromChat)) {
169
+ for (const t of fromChat) {
170
+ if (t && typeof t === 'object') {
171
+ register(t);
172
+ }
173
+ }
174
+ }
175
+ if (Array.isArray(originalTools)) {
176
+ for (const t of originalTools) {
177
+ if (!t || typeof t !== 'object')
178
+ continue;
179
+ const typeName = norm(t.type);
180
+ const isWebSearch = typeName === 'web_search' || (typeof typeName === 'string' && typeName.startsWith('web_search'));
181
+ if (!isWebSearch) {
182
+ // 目前仅恢复原始载荷中的 builtin web_search 工具,其它非函数工具保持忽略,避免意外改变行为。
183
+ continue;
184
+ }
185
+ register(t);
186
+ }
187
+ }
188
+ return result.length ? result : undefined;
189
+ }
149
190
  export function buildResponsesRequestFromChat(payload, ctx, extras) {
150
191
  const chat = unwrapData(payload);
151
192
  const out = {};
152
193
  // 基本字段
153
194
  out.model = chat.model;
154
195
  // tools: 反向映射为 ResponsesToolDefinition 形状
155
- const responsesTools = mapChatToolsToBridge(chat.tools, {
196
+ const responsesToolsFromChat = mapChatToolsToBridge(chat.tools, {
156
197
  sanitizeName: sanitizeResponsesFunctionName
157
198
  });
158
- if (responsesTools?.length) {
159
- out.tools = responsesTools;
199
+ // Prefer Chat‑normalized tools, but if the original Responses payload carried
200
+ // non‑function tools (such as builtin `web_search`), merge them back so that
201
+ // upstream `/v1/responses` providers see their original tool definitions.
202
+ const originalTools = Array.isArray(ctx?.toolsRaw) ? ctx.toolsRaw : undefined;
203
+ const mergedTools = mergeResponsesTools(originalTools, responsesToolsFromChat);
204
+ if (mergedTools?.length) {
205
+ out.tools = mergedTools;
160
206
  }
161
207
  const passthroughKeys = [
162
208
  'tool_choice',
@@ -1,35 +1,25 @@
1
- import os from 'node:os';
2
- import path from 'node:path';
3
- import fs from 'node:fs/promises';
4
1
  import { writeSnapshotViaHooks } from './snapshot-hooks.js';
5
- const SNAPSHOT_BASE = path.join(os.homedir(), '.routecodex', 'golden_samples');
6
- function mapEndpointToFolder(endpoint, hint) {
7
- if (hint)
8
- return hint;
9
- const ep = String(endpoint || '').toLowerCase();
10
- if (ep.includes('/responses'))
11
- return 'openai-responses';
12
- if (ep.includes('/messages'))
13
- return 'anthropic-messages';
14
- if (ep.includes('/gemini'))
15
- return 'gemini-chat';
16
- return 'openai-chat';
17
- }
18
- async function ensureDir(dir) {
19
- try {
20
- await fs.mkdir(dir, { recursive: true });
2
+ function resolveBoolFromEnv(value, fallback) {
3
+ if (!value) {
4
+ return fallback;
21
5
  }
22
- catch {
23
- // ignore fs errors
6
+ const normalized = value.trim().toLowerCase();
7
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) {
8
+ return true;
24
9
  }
25
- }
26
- function sanitize(value) {
27
- return value.replace(/[^\w.-]/g, '_');
10
+ if (['0', 'false', 'no', 'off'].includes(normalized)) {
11
+ return false;
12
+ }
13
+ return fallback;
28
14
  }
29
15
  export function shouldRecordSnapshots() {
30
- const flag = process.env.ROUTECODEX_HUB_SNAPSHOTS;
31
- if (flag && flag.trim() === '0') {
32
- return false;
16
+ const hubFlag = process.env.ROUTECODEX_HUB_SNAPSHOTS;
17
+ if (hubFlag && hubFlag.trim().length) {
18
+ return resolveBoolFromEnv(hubFlag, true);
19
+ }
20
+ const sharedFlag = process.env.ROUTECODEX_SNAPSHOT ?? process.env.ROUTECODEX_SNAPSHOTS;
21
+ if (sharedFlag && sharedFlag.trim().length) {
22
+ return resolveBoolFromEnv(sharedFlag, true);
33
23
  }
34
24
  return true;
35
25
  }
@@ -37,26 +27,6 @@ export async function recordSnapshot(options) {
37
27
  if (!shouldRecordSnapshots())
38
28
  return;
39
29
  const endpoint = options.endpoint || '/v1/chat/completions';
40
- const folder = mapEndpointToFolder(endpoint, options.folderHint);
41
- const dir = path.join(SNAPSHOT_BASE, folder);
42
- try {
43
- await ensureDir(dir);
44
- const safeStage = sanitize(options.stage);
45
- const safeRequestId = sanitize(options.requestId);
46
- const file = path.join(dir, `${safeRequestId}_${safeStage}.json`);
47
- const payload = {
48
- meta: {
49
- stage: options.stage,
50
- timestamp: Date.now(),
51
- endpoint
52
- },
53
- body: options.data
54
- };
55
- await fs.writeFile(file, JSON.stringify(payload, null, 2), 'utf-8');
56
- }
57
- catch (error) {
58
- console.warn('[snapshot-utils] failed to write snapshot', error);
59
- }
60
30
  void writeSnapshotViaHooks({
61
31
  endpoint,
62
32
  stage: options.stage,