@jsonstudio/llms 0.6.567 → 0.6.586

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 (62) hide show
  1. package/dist/conversion/codecs/gemini-openai-codec.js +33 -4
  2. package/dist/conversion/codecs/openai-openai-codec.js +2 -1
  3. package/dist/conversion/codecs/responses-openai-codec.js +3 -2
  4. package/dist/conversion/compat/actions/glm-history-image-trim.d.ts +2 -0
  5. package/dist/conversion/compat/actions/glm-history-image-trim.js +88 -0
  6. package/dist/conversion/hub/pipeline/hub-pipeline.d.ts +6 -2
  7. package/dist/conversion/hub/pipeline/hub-pipeline.js +72 -81
  8. package/dist/conversion/hub/pipeline/stages/resp_outbound/resp_outbound_stage1_client_remap/index.js +0 -34
  9. package/dist/conversion/hub/process/chat-process.js +68 -24
  10. package/dist/conversion/hub/response/provider-response.js +0 -8
  11. package/dist/conversion/hub/semantic-mappers/gemini-mapper.js +22 -3
  12. package/dist/conversion/hub/semantic-mappers/responses-mapper.js +267 -14
  13. package/dist/conversion/hub/types/chat-envelope.d.ts +1 -0
  14. package/dist/conversion/responses/responses-openai-bridge.d.ts +3 -2
  15. package/dist/conversion/responses/responses-openai-bridge.js +1 -13
  16. package/dist/conversion/shared/anthropic-message-utils.js +54 -0
  17. package/dist/conversion/shared/args-mapping.js +11 -3
  18. package/dist/conversion/shared/responses-output-builder.js +42 -21
  19. package/dist/conversion/shared/streaming-text-extractor.d.ts +25 -0
  20. package/dist/conversion/shared/streaming-text-extractor.js +31 -38
  21. package/dist/conversion/shared/text-markup-normalizer.d.ts +20 -0
  22. package/dist/conversion/shared/text-markup-normalizer.js +118 -31
  23. package/dist/conversion/shared/tool-filter-pipeline.js +56 -30
  24. package/dist/conversion/shared/tool-harvester.js +43 -12
  25. package/dist/conversion/shared/tool-mapping.d.ts +1 -0
  26. package/dist/conversion/shared/tool-mapping.js +33 -19
  27. package/dist/filters/index.d.ts +1 -0
  28. package/dist/filters/index.js +1 -0
  29. package/dist/filters/special/request-tools-normalize.js +14 -4
  30. package/dist/filters/special/response-apply-patch-toon-decode.d.ts +23 -0
  31. package/dist/filters/special/response-apply-patch-toon-decode.js +117 -0
  32. package/dist/filters/special/response-tool-arguments-toon-decode.d.ts +10 -0
  33. package/dist/filters/special/response-tool-arguments-toon-decode.js +154 -26
  34. package/dist/guidance/index.js +71 -42
  35. package/dist/router/virtual-router/bootstrap.js +10 -5
  36. package/dist/router/virtual-router/classifier.js +16 -7
  37. package/dist/router/virtual-router/engine-health.d.ts +11 -0
  38. package/dist/router/virtual-router/engine-health.js +217 -4
  39. package/dist/router/virtual-router/engine-logging.d.ts +2 -1
  40. package/dist/router/virtual-router/engine-logging.js +35 -3
  41. package/dist/router/virtual-router/engine.d.ts +17 -1
  42. package/dist/router/virtual-router/engine.js +184 -6
  43. package/dist/router/virtual-router/routing-instructions.d.ts +2 -0
  44. package/dist/router/virtual-router/routing-instructions.js +19 -1
  45. package/dist/router/virtual-router/tool-signals.d.ts +2 -1
  46. package/dist/router/virtual-router/tool-signals.js +324 -119
  47. package/dist/router/virtual-router/types.d.ts +31 -1
  48. package/dist/router/virtual-router/types.js +2 -2
  49. package/dist/servertool/engine.js +3 -0
  50. package/dist/servertool/handlers/iflow-model-error-retry.d.ts +1 -0
  51. package/dist/servertool/handlers/iflow-model-error-retry.js +93 -0
  52. package/dist/servertool/handlers/stop-message-auto.js +61 -4
  53. package/dist/servertool/server-side-tools.d.ts +1 -0
  54. package/dist/servertool/server-side-tools.js +27 -0
  55. package/dist/sse/json-to-sse/event-generators/responses.js +9 -2
  56. package/dist/sse/sse-to-json/builders/anthropic-response-builder.js +23 -3
  57. package/dist/tools/apply-patch-structured.d.ts +20 -0
  58. package/dist/tools/apply-patch-structured.js +240 -0
  59. package/dist/tools/tool-description-utils.d.ts +5 -0
  60. package/dist/tools/tool-description-utils.js +50 -0
  61. package/dist/tools/tool-registry.js +11 -193
  62. package/package.json +1 -1
@@ -360,7 +360,9 @@ export function buildOpenAIChatFromGeminiResponse(payload) {
360
360
  if (Number.isFinite(totalTokens))
361
361
  usage.total_tokens = totalTokens;
362
362
  const combinedText = textParts.join('\n');
363
- const normalized = combinedText.length ? normalizeChatMessageContent(combinedText) : { contentText: undefined, reasoningText: undefined };
363
+ const normalized = combinedText.length
364
+ ? normalizeChatMessageContent(combinedText)
365
+ : { contentText: undefined, reasoningText: undefined };
364
366
  const baseContent = normalized.contentText ?? combinedText ?? '';
365
367
  const toolResultBlock = toolResultTexts.length ? toolResultTexts.join('\n') : '';
366
368
  const finalContent = toolResultBlock && baseContent
@@ -525,16 +527,43 @@ export function buildGeminiFromOpenAIChat(chatResp) {
525
527
  let argsStruct;
526
528
  const rawArgs = fn.arguments;
527
529
  if (typeof rawArgs === 'string') {
530
+ const trimmed = rawArgs.trim();
531
+ if (trimmed.startsWith('{')) {
532
+ try {
533
+ const parsed = JSON.parse(rawArgs);
534
+ if (isObject(parsed)) {
535
+ argsStruct = parsed;
536
+ }
537
+ else {
538
+ argsStruct = { _raw: rawArgs };
539
+ }
540
+ }
541
+ catch {
542
+ argsStruct = { _raw: rawArgs };
543
+ }
544
+ }
545
+ else {
546
+ argsStruct = { _raw: rawArgs };
547
+ }
548
+ }
549
+ else if (isObject(rawArgs)) {
550
+ argsStruct = rawArgs;
551
+ }
552
+ else if (Array.isArray(rawArgs)) {
528
553
  try {
529
- argsStruct = JSON.parse(rawArgs);
554
+ argsStruct = { _raw: JSON.stringify(rawArgs) };
530
555
  }
531
556
  catch {
532
- argsStruct = { _raw: rawArgs };
557
+ argsStruct = { _raw: String(rawArgs) };
533
558
  }
534
559
  }
560
+ else if (rawArgs != null) {
561
+ argsStruct = { _raw: String(rawArgs) };
562
+ }
535
563
  else {
536
- argsStruct = rawArgs ?? {};
564
+ argsStruct = {};
537
565
  }
566
+ // Gemini request/response wire uses `args` for functionCall payload.
538
567
  const functionCall = { name, args: argsStruct };
539
568
  const id = typeof tc.id === 'string' ? String(tc.id) : undefined;
540
569
  if (id)
@@ -89,8 +89,9 @@ export class OpenAIOpenAIConversionCodec {
89
89
  // Response-side filters (idempotent w.r.t existing logic)
90
90
  engine.registerFilter(new ResponseToolTextCanonicalizeFilter()); // response_pre
91
91
  try {
92
- const { ResponseToolArgumentsToonDecodeFilter } = await import('../../filters/index.js');
92
+ const { ResponseToolArgumentsToonDecodeFilter, ResponseApplyPatchToonDecodeFilter } = await import('../../filters/index.js');
93
93
  engine.registerFilter(new ResponseToolArgumentsToonDecodeFilter()); // response_pre, runs before stringify
94
+ engine.registerFilter(new ResponseApplyPatchToonDecodeFilter()); // response_pre, runs before stringify
94
95
  }
95
96
  catch { /* optional */ }
96
97
  engine.registerFilter(new ResponseToolArgumentsStringifyFilter()); // response_post
@@ -147,11 +147,12 @@ export class ResponsesOpenAIConversionCodec {
147
147
  debug: { emit: () => { } }
148
148
  };
149
149
  const engine = new FilterEngine();
150
- // Response-side filters:文本标准化 → TOON decode(可选)→ shell/basics → finish_reason 不变式
150
+ // Response-side filters:文本标准化 → TOON decode(可选)→ apply_patch 结构化补丁规范化 → shell/basics → finish_reason 不变式
151
151
  engine.registerFilter(new ResponseToolTextCanonicalizeFilter()); // response_pre
152
152
  try {
153
- const { ResponseToolArgumentsToonDecodeFilter } = await import('../../filters/index.js');
153
+ const { ResponseToolArgumentsToonDecodeFilter, ResponseApplyPatchToonDecodeFilter } = await import('../../filters/index.js');
154
154
  engine.registerFilter(new ResponseToolArgumentsToonDecodeFilter()); // response_pre, runs before stringify
155
+ engine.registerFilter(new ResponseApplyPatchToonDecodeFilter()); // response_pre, runs before stringify
155
156
  }
156
157
  catch { /* optional */ }
157
158
  engine.registerFilter(new ResponseToolArgumentsStringifyFilter()); // response_post
@@ -0,0 +1,2 @@
1
+ import type { JsonObject } from '../../hub/types/json.js';
2
+ export declare function applyGlmHistoryImageTrim(payload: JsonObject): JsonObject;
@@ -0,0 +1,88 @@
1
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
2
+ function shouldDropInlineImagePart(part) {
3
+ const rawType = typeof part.type === 'string' ? part.type.toLowerCase() : '';
4
+ if (rawType !== 'image' && rawType !== 'image_url' && rawType !== 'input_image') {
5
+ return false;
6
+ }
7
+ const imageUrlBlock = isRecord(part.image_url)
8
+ ? part.image_url
9
+ : part;
10
+ const urlRaw = typeof imageUrlBlock.url === 'string'
11
+ ? imageUrlBlock.url
12
+ : typeof imageUrlBlock.data === 'string'
13
+ ? imageUrlBlock.data
14
+ : '';
15
+ const url = urlRaw.trim();
16
+ if (!url) {
17
+ return false;
18
+ }
19
+ // GLM 4.7 在历史消息中携带 data:image/base64 时会返回 1210,
20
+ // 因此仅在历史中丢弃这类 inline image 片段。
21
+ return url.startsWith('data:image');
22
+ }
23
+ export function applyGlmHistoryImageTrim(payload) {
24
+ const root = structuredClone(payload);
25
+ const modelRaw = root.model;
26
+ const modelId = typeof modelRaw === 'string' ? modelRaw.trim().toLowerCase() : '';
27
+ if (!modelId || !modelId.startsWith('glm-4.7')) {
28
+ return root;
29
+ }
30
+ const messagesValue = root.messages;
31
+ if (!Array.isArray(messagesValue)) {
32
+ return root;
33
+ }
34
+ const messages = messagesValue.filter(msg => isRecord(msg));
35
+ if (!messages.length) {
36
+ return root;
37
+ }
38
+ // 仅在历史消息中进行裁剪:保留最后一条 user 完整内容。
39
+ let lastUserIdx = -1;
40
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
41
+ const msg = messages[i];
42
+ const role = typeof msg.role === 'string' ? msg.role.toLowerCase() : '';
43
+ if (role === 'user') {
44
+ lastUserIdx = i;
45
+ break;
46
+ }
47
+ }
48
+ if (lastUserIdx === -1) {
49
+ return root;
50
+ }
51
+ const nextMessages = [];
52
+ for (let i = 0; i < messages.length; i += 1) {
53
+ const msg = messages[i];
54
+ const role = typeof msg.role === 'string' ? msg.role.toLowerCase() : '';
55
+ if (i < lastUserIdx && role === 'user') {
56
+ const contentValue = msg.content;
57
+ if (!Array.isArray(contentValue)) {
58
+ nextMessages.push(msg);
59
+ continue;
60
+ }
61
+ const newContent = [];
62
+ for (const part of contentValue) {
63
+ if (!isRecord(part)) {
64
+ newContent.push(part);
65
+ continue;
66
+ }
67
+ if (shouldDropInlineImagePart(part)) {
68
+ // 丢弃历史中的 data:image/* 片段
69
+ // eslint-disable-next-line no-continue
70
+ continue;
71
+ }
72
+ newContent.push(part);
73
+ }
74
+ if (!newContent.length) {
75
+ // 历史消息只剩下 inline image 时,直接移除整条消息。
76
+ // 避免向 GLM 发送纯图片历史导致 1210。
77
+ // eslint-disable-next-line no-continue
78
+ continue;
79
+ }
80
+ const cloned = { ...msg, content: newContent };
81
+ nextMessages.push(cloned);
82
+ continue;
83
+ }
84
+ nextMessages.push(msg);
85
+ }
86
+ root.messages = nextMessages;
87
+ return root;
88
+ }
@@ -1,10 +1,15 @@
1
1
  import { Readable } from 'node:stream';
2
2
  import type { StandardizedRequest, ProcessedRequest } from '../types/standardized.js';
3
3
  import type { JsonObject } from '../types/json.js';
4
- import type { VirtualRouterConfig, RoutingDecision, RoutingDiagnostics, TargetMetadata } from '../../../router/virtual-router/types.js';
4
+ import type { VirtualRouterConfig, RoutingDecision, RoutingDiagnostics, TargetMetadata, VirtualRouterHealthStore } from '../../../router/virtual-router/types.js';
5
5
  import { type HubProcessNodeResult } from '../process/chat-process.js';
6
6
  export interface HubPipelineConfig {
7
7
  virtualRouter: VirtualRouterConfig;
8
+ /**
9
+ * 可选:供 VirtualRouterEngine 使用的健康状态持久化存储。
10
+ * 当提供时,VirtualRouterEngine 将在初始化时恢复上一次快照,并在 cooldown/熔断变化时调用 persistSnapshot。
11
+ */
12
+ healthStore?: VirtualRouterHealthStore;
8
13
  }
9
14
  export interface HubPipelineRequestMetadata extends Record<string, unknown> {
10
15
  entryEndpoint?: string;
@@ -56,7 +61,6 @@ export declare class HubPipeline {
56
61
  private buildAdapterContext;
57
62
  private maybeCreateStageRecorder;
58
63
  private asJsonObject;
59
- private pickRawRequestBody;
60
64
  private normalizeRequest;
61
65
  private convertProcessNodeResult;
62
66
  private materializePayload;
@@ -22,13 +22,16 @@ import { runReqOutboundStage1SemanticMap } from './stages/req_outbound/req_outbo
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
24
  import { extractSessionIdentifiersFromMetadata } from './session-identifiers.js';
25
+ import { computeRequestTokens } from '../../../router/virtual-router/token-estimator.js';
25
26
  export class HubPipeline {
26
27
  routerEngine;
27
28
  config;
28
29
  unsubscribeProviderErrors;
29
30
  constructor(config) {
30
31
  this.config = config;
31
- this.routerEngine = new VirtualRouterEngine();
32
+ this.routerEngine = new VirtualRouterEngine({
33
+ healthStore: config.healthStore
34
+ });
32
35
  this.routerEngine.initialize(config.virtualRouter);
33
36
  try {
34
37
  this.unsubscribeProviderErrors = providerErrorCenter.subscribe((event) => {
@@ -117,6 +120,18 @@ export class HubPipeline {
117
120
  }
118
121
  }
119
122
  const workingRequest = processedRequest ?? standardizedRequest;
123
+ // 使用与 VirtualRouter 一致的 tiktoken 计数逻辑,对标准化请求进行一次
124
+ // 上下文 token 估算,供后续 usage 归一化与统计使用。
125
+ try {
126
+ const estimatedTokens = computeRequestTokens(workingRequest, '');
127
+ if (typeof estimatedTokens === 'number' && Number.isFinite(estimatedTokens) && estimatedTokens > 0) {
128
+ normalized.metadata = normalized.metadata || {};
129
+ normalized.metadata.estimatedInputTokens = estimatedTokens;
130
+ }
131
+ }
132
+ catch {
133
+ // 估算失败不应影响主流程
134
+ }
120
135
  const normalizedMeta = normalized.metadata;
121
136
  const responsesResume = normalizedMeta && typeof normalizedMeta.responsesResume === 'object'
122
137
  ? normalizedMeta.responsesResume
@@ -180,81 +195,60 @@ export class HubPipeline {
180
195
  const outboundEndpoint = resolveEndpointForProviderProtocol(outboundAdapterContext.providerProtocol);
181
196
  const outboundRecorder = this.maybeCreateStageRecorder(outboundAdapterContext, outboundEndpoint);
182
197
  const outboundStart = Date.now();
183
- const isResponsesSubmit = normalized.entryEndpoint === '/v1/responses.submit_tool_outputs' &&
184
- outboundProtocol === 'openai-responses';
185
198
  let providerPayload;
186
- if (isResponsesSubmit) {
187
- providerPayload =
188
- this.pickRawRequestBody(normalized.metadata) ?? normalized.payload;
189
- const outboundEnd = Date.now();
190
- nodeResults.push({
191
- id: 'req_outbound',
192
- success: true,
193
- metadata: {
194
- node: 'req_outbound',
195
- executionTime: outboundEnd - outboundStart,
196
- startTime: outboundStart,
197
- endTime: outboundEnd,
198
- dataProcessed: {
199
- messages: 0,
200
- tools: 0
201
- },
202
- bypass: 'responses-submit-passthrough'
203
- }
204
- });
205
- }
206
- else {
207
- const outboundStage1 = await runReqOutboundStage1SemanticMap({
208
- request: workingRequest,
209
- adapterContext: outboundAdapterContext,
210
- semanticMapper: outboundSemanticMapper,
211
- contextSnapshot: outboundContextSnapshot,
212
- contextMetadataKey: outboundContextMetadataKey,
213
- stageRecorder: outboundRecorder
214
- });
215
- let formattedPayload = await runReqOutboundStage2FormatBuild({
216
- formatEnvelope: outboundStage1.formatEnvelope,
217
- adapterContext: outboundAdapterContext,
218
- formatAdapter: outboundFormatAdapter,
219
- stageRecorder: outboundRecorder
220
- });
221
- formattedPayload = await runReqOutboundStage3Compat({
222
- payload: formattedPayload,
223
- adapterContext: outboundAdapterContext,
224
- stageRecorder: outboundRecorder
225
- });
226
- providerPayload = formattedPayload;
227
- const outboundEnd = Date.now();
228
- nodeResults.push({
229
- id: 'req_outbound',
230
- success: true,
231
- metadata: {
232
- node: 'req_outbound',
233
- executionTime: outboundEnd - outboundStart,
234
- startTime: outboundStart,
235
- endTime: outboundEnd,
236
- dataProcessed: {
237
- messages: workingRequest.messages.length,
238
- tools: workingRequest.tools?.length ?? 0
239
- }
199
+ const outboundStage1 = await runReqOutboundStage1SemanticMap({
200
+ request: workingRequest,
201
+ adapterContext: outboundAdapterContext,
202
+ semanticMapper: outboundSemanticMapper,
203
+ contextSnapshot: outboundContextSnapshot,
204
+ contextMetadataKey: outboundContextMetadataKey,
205
+ stageRecorder: outboundRecorder
206
+ });
207
+ let formattedPayload = await runReqOutboundStage2FormatBuild({
208
+ formatEnvelope: outboundStage1.formatEnvelope,
209
+ adapterContext: outboundAdapterContext,
210
+ formatAdapter: outboundFormatAdapter,
211
+ stageRecorder: outboundRecorder
212
+ });
213
+ formattedPayload = await runReqOutboundStage3Compat({
214
+ payload: formattedPayload,
215
+ adapterContext: outboundAdapterContext,
216
+ stageRecorder: outboundRecorder
217
+ });
218
+ providerPayload = formattedPayload;
219
+ const outboundEnd = Date.now();
220
+ nodeResults.push({
221
+ id: 'req_outbound',
222
+ success: true,
223
+ metadata: {
224
+ node: 'req_outbound',
225
+ executionTime: outboundEnd - outboundStart,
226
+ startTime: outboundStart,
227
+ endTime: outboundEnd,
228
+ dataProcessed: {
229
+ messages: workingRequest.messages.length,
230
+ tools: workingRequest.tools?.length ?? 0
240
231
  }
241
- });
242
- }
232
+ }
233
+ });
243
234
  // 为响应侧 servertool/web_search 提供一次性 Chat 请求快照,便于在 Hub 内部实现
244
235
  // 第三跳(将工具结果注入消息历史后重新调用主模型)。
236
+ //
237
+ // 注意:这里不再根据 processMode(passthrough/chat) 做分支判断——即使某些
238
+ // route 将 processMode 标记为 passthrough,我们仍然需要保留一次规范化后的
239
+ // Chat 请求快照,供 stopMessage / gemini_empty_reply_continue 等被动触发型
240
+ // servertool 在响应阶段使用。
245
241
  let capturedChatRequest;
246
- if (normalized.processMode !== 'passthrough') {
247
- try {
248
- capturedChatRequest = JSON.parse(JSON.stringify({
249
- model: workingRequest.model,
250
- messages: workingRequest.messages,
251
- tools: workingRequest.tools,
252
- parameters: workingRequest.parameters
253
- }));
254
- }
255
- catch {
256
- capturedChatRequest = undefined;
257
- }
242
+ try {
243
+ capturedChatRequest = JSON.parse(JSON.stringify({
244
+ model: workingRequest.model,
245
+ messages: workingRequest.messages,
246
+ tools: workingRequest.tools,
247
+ parameters: workingRequest.parameters
248
+ }));
249
+ }
250
+ catch {
251
+ capturedChatRequest = undefined;
258
252
  }
259
253
  const metadata = {
260
254
  ...normalized.metadata,
@@ -397,6 +391,13 @@ export class HubPipeline {
397
391
  if (conversationId) {
398
392
  adapterContext.conversationId = conversationId;
399
393
  }
394
+ const responsesResume = metadata.responsesResume &&
395
+ typeof metadata.responsesResume === 'object'
396
+ ? metadata.responsesResume
397
+ : undefined;
398
+ if (responsesResume) {
399
+ adapterContext.responsesResume = responsesResume;
400
+ }
400
401
  if (target?.compatibilityProfile && typeof target.compatibilityProfile === 'string') {
401
402
  adapterContext.compatibilityProfile = target.compatibilityProfile;
402
403
  }
@@ -420,16 +421,6 @@ export class HubPipeline {
420
421
  }
421
422
  return value;
422
423
  }
423
- pickRawRequestBody(metadata) {
424
- if (!metadata || typeof metadata !== 'object') {
425
- return undefined;
426
- }
427
- const raw = metadata.__raw_request_body;
428
- if (!raw || typeof raw !== 'object') {
429
- return undefined;
430
- }
431
- return raw;
432
- }
433
424
  async normalizeRequest(request) {
434
425
  if (!request || typeof request !== 'object') {
435
426
  throw new Error('HubPipeline requires request payload');
@@ -15,7 +15,6 @@ export function runRespOutboundStage1ClientRemap(options) {
15
15
  clientPayload = buildResponsesPayloadFromChat(options.payload, {
16
16
  requestId: options.requestId
17
17
  });
18
- mergeOriginalResponsesPayload(clientPayload, options.adapterContext);
19
18
  }
20
19
  recordStage(options.stageRecorder, 'resp_outbound_stage1_client_remap', clientPayload);
21
20
  return clientPayload;
@@ -42,36 +41,3 @@ function resolveAliasMapFromContext(adapterContext) {
42
41
  }
43
42
  return Object.keys(map).length ? map : undefined;
44
43
  }
45
- function mergeOriginalResponsesPayload(payload, adapterContext) {
46
- if (!adapterContext) {
47
- return;
48
- }
49
- const raw = adapterContext.__raw_responses_payload;
50
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
51
- return;
52
- }
53
- try {
54
- if (payload.required_action == null && raw.required_action != null) {
55
- payload.required_action = JSON.parse(JSON.stringify(raw.required_action));
56
- }
57
- }
58
- catch {
59
- /* ignore clone errors */
60
- }
61
- const rawStatus = typeof raw.status === 'string' ? raw.status : undefined;
62
- if (rawStatus === 'requires_action') {
63
- payload.status = 'requires_action';
64
- }
65
- // 如果桥接后的 payload 没有 usage,而原始 Responses 载荷带有 usage,则回填原始 usage,
66
- // 确保 token usage 不在工具/桥接路径中丢失。
67
- const payloadUsage = payload.usage;
68
- const rawUsage = raw.usage;
69
- if ((payloadUsage == null || typeof payloadUsage !== 'object') && rawUsage && typeof rawUsage === 'object') {
70
- try {
71
- payload.usage = JSON.parse(JSON.stringify(rawUsage));
72
- }
73
- catch {
74
- payload.usage = rawUsage;
75
- }
76
- }
77
- }
@@ -1,6 +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
+ import { ensureApplyPatchSchema } from '../../shared/tool-mapping.js';
4
4
  const toolGovernanceEngine = new ToolGovernanceEngine();
5
5
  export async function runHubChatProcess(options) {
6
6
  const startTime = Date.now();
@@ -72,6 +72,12 @@ async function applyRequestToolGovernance(request, context) {
72
72
  governanceTimestamp: Date.now()
73
73
  }
74
74
  };
75
+ // 清理历史图片:仅保留「最新一条 user 消息」中的图片分段,
76
+ // 避免历史对话中的图片在后续多轮工具 / 普通对话中继续作为多模态负载发给不支持图片的模型。
77
+ merged = {
78
+ ...merged,
79
+ messages: stripHistoricalImageAttachments(merged.messages)
80
+ };
75
81
  if (containsImageAttachment(merged.messages)) {
76
82
  if (!merged.metadata) {
77
83
  merged.metadata = {
@@ -205,6 +211,63 @@ function castSingleTool(tool) {
205
211
  }
206
212
  };
207
213
  }
214
+ function stripHistoricalImageAttachments(messages) {
215
+ if (!Array.isArray(messages) || !messages.length) {
216
+ return messages;
217
+ }
218
+ // 找到最新一条 user 消息,仅允许该消息保留图片分段;
219
+ // 更早的 user 消息中若存在图片,则移除其 image* 分段,保留纯文本与非图片内容。
220
+ let latestUserIndex = -1;
221
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
222
+ const candidate = messages[idx];
223
+ if (candidate && typeof candidate === 'object' && candidate.role === 'user') {
224
+ latestUserIndex = idx;
225
+ break;
226
+ }
227
+ }
228
+ if (latestUserIndex < 0) {
229
+ return messages;
230
+ }
231
+ let changed = false;
232
+ const next = messages.slice();
233
+ for (let idx = 0; idx < messages.length; idx += 1) {
234
+ if (idx === latestUserIndex) {
235
+ continue;
236
+ }
237
+ const message = messages[idx];
238
+ if (!message || typeof message !== 'object') {
239
+ continue;
240
+ }
241
+ if (message.role !== 'user') {
242
+ continue;
243
+ }
244
+ const content = message.content;
245
+ if (!Array.isArray(content) || !content.length) {
246
+ continue;
247
+ }
248
+ const filtered = [];
249
+ let removed = false;
250
+ for (const part of content) {
251
+ if (part && typeof part === 'object' && !Array.isArray(part)) {
252
+ const typeValue = part.type;
253
+ if (typeof typeValue === 'string' && typeValue.toLowerCase().includes('image')) {
254
+ removed = true;
255
+ continue;
256
+ }
257
+ }
258
+ filtered.push(part);
259
+ }
260
+ if (removed) {
261
+ const cloned = {
262
+ ...message,
263
+ content: filtered
264
+ };
265
+ next[idx] = cloned;
266
+ changed = true;
267
+ }
268
+ }
269
+ return changed ? next : messages;
270
+ }
208
271
  function containsImageAttachment(messages) {
209
272
  if (!Array.isArray(messages) || !messages.length) {
210
273
  return false;
@@ -278,17 +341,7 @@ function castCustomTool(tool) {
278
341
  function: {
279
342
  name: 'apply_patch',
280
343
  description,
281
- parameters: {
282
- type: 'object',
283
- properties: {
284
- patch: {
285
- type: 'string',
286
- description: 'Unified diff patch content (FREEFORM, not JSON)'
287
- }
288
- },
289
- required: ['patch'],
290
- additionalProperties: false
291
- },
344
+ parameters: ensureApplyPatchSchema(),
292
345
  strict: true
293
346
  }
294
347
  };
@@ -355,19 +408,10 @@ function maybeInjectWebSearchTool(request, metadata) {
355
408
  : 'selective';
356
409
  const intent = detectWebSearchIntent(request);
357
410
  if (injectPolicy === 'selective') {
411
+ // 仅当当前这一轮用户输入明确表达“联网搜索”意图时才注入 web_search。
412
+ // 不再依赖上一轮工具分类(read/search/websearch),避免形成隐式续写语义。
358
413
  if (!intent.hasIntent) {
359
- // 当最近一条用户消息没有明显的“联网搜索”关键词时,
360
- // 如果上一轮 assistant 的工具调用已经属于搜索类(如 web_search),
361
- // 则仍然视为 web_search 续写场景,强制注入 web_search 工具,
362
- // 以便在后续路由中按 servertool 逻辑跳过不适配的 Provider(例如 serverToolsDisabled 的 crs)。
363
- const assistantMessages = Array.isArray(request.messages)
364
- ? request.messages.filter((msg) => msg && msg.role === 'assistant')
365
- : [];
366
- const lastTool = detectLastAssistantToolCategory(assistantMessages);
367
- const hasSearchToolContext = lastTool?.category === 'search';
368
- if (!hasSearchToolContext) {
369
- return request;
370
- }
414
+ return request;
371
415
  }
372
416
  }
373
417
  const existingTools = Array.isArray(request.tools) ? request.tools : [];
@@ -130,14 +130,6 @@ export async function convertProviderResponse(options) {
130
130
  catch {
131
131
  // ignore conversation capture errors
132
132
  }
133
- if (formatEnvelope.payload && typeof formatEnvelope.payload === 'object') {
134
- try {
135
- options.context.__raw_responses_payload = JSON.parse(JSON.stringify(formatEnvelope.payload));
136
- }
137
- catch {
138
- /* best-effort clone */
139
- }
140
- }
141
133
  }
142
134
  formatEnvelope.payload = runRespInboundStageCompatResponse({
143
135
  payload: formatEnvelope.payload,
@@ -286,6 +286,14 @@ function appendChatContentToGeminiParts(message, targetParts) {
286
286
  function buildGeminiRequestFromChat(chat, metadata) {
287
287
  const contents = [];
288
288
  const emittedToolOutputs = new Set();
289
+ const adapterContext = metadata?.context;
290
+ const rawProviderId = adapterContext?.providerId;
291
+ const normalizedProviderId = typeof rawProviderId === 'string' ? rawProviderId.toLowerCase() : '';
292
+ const providerIdPrefix = normalizedProviderId.split('.')[0];
293
+ // 保持对通用 gemini-cli 的保护(避免上游直接执行 functionCall),
294
+ // 但对于 antigravity.* 明确允许通过 Gemini functionCall 协议执行工具,
295
+ // 以便完整打通 tools → functionCall → functionResponse 链路。
296
+ const omitFunctionCallPartsForCli = providerIdPrefix === 'gemini-cli';
289
297
  for (const message of chat.messages) {
290
298
  if (!message || typeof message !== 'object')
291
299
  continue;
@@ -304,10 +312,15 @@ function buildGeminiRequestFromChat(chat, metadata) {
304
312
  parts: []
305
313
  };
306
314
  appendChatContentToGeminiParts(message, entry.parts);
307
- const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
315
+ const toolCalls = Array.isArray(message.tool_calls)
316
+ ? message.tool_calls
317
+ : [];
308
318
  for (const tc of toolCalls) {
309
319
  if (!tc || typeof tc !== 'object')
310
320
  continue;
321
+ if (omitFunctionCallPartsForCli) {
322
+ continue;
323
+ }
311
324
  const fn = tc.function || {};
312
325
  const name = typeof fn.name === 'string' ? fn.name : undefined;
313
326
  if (!name)
@@ -324,7 +337,14 @@ function buildGeminiRequestFromChat(chat, metadata) {
324
337
  else {
325
338
  argsStruct = fn.arguments ?? {};
326
339
  }
327
- const part = { functionCall: { name, args: cloneAsJsonValue(argsStruct) } };
340
+ let argsJson = cloneAsJsonValue(argsStruct);
341
+ // Gemini / Antigravity 期望 functionCall.args 为对象(Struct),
342
+ // 若顶层为数组或原始类型,则包装到 value 字段下,避免产生非法的 list 形状。
343
+ if (!argsJson || typeof argsJson !== 'object' || Array.isArray(argsJson)) {
344
+ argsJson = { value: argsJson };
345
+ }
346
+ const functionCall = { name, args: argsJson };
347
+ const part = { functionCall };
328
348
  if (typeof tc.id === 'string') {
329
349
  part.functionCall.id = tc.id;
330
350
  }
@@ -409,7 +429,6 @@ function buildGeminiRequestFromChat(chat, metadata) {
409
429
  }
410
430
  // Apply claude-thinking compat directly at Gemini mapping time to ensure it is always active
411
431
  // for antigravity.*.claude-sonnet-4-5-thinking, regardless of compatibilityProfile wiring.
412
- const adapterContext = metadata?.context;
413
432
  const compatRequest = applyClaudeThinkingToolSchemaCompat(request, adapterContext);
414
433
  return compatRequest;
415
434
  }