@jsonstudio/llms 0.6.1643 → 0.6.1739

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 (96) hide show
  1. package/dist/conversion/compat/actions/harvest-tool-calls-from-text.d.ts +10 -0
  2. package/dist/conversion/compat/actions/harvest-tool-calls-from-text.js +121 -0
  3. package/dist/conversion/compat/actions/iflow-kimi-cli-defaults.d.ts +10 -0
  4. package/dist/conversion/compat/actions/iflow-kimi-cli-defaults.js +80 -0
  5. package/dist/conversion/compat/actions/iflow-kimi-history-media-placeholder.d.ts +7 -0
  6. package/dist/conversion/compat/actions/iflow-kimi-history-media-placeholder.js +161 -0
  7. package/dist/conversion/compat/actions/iflow-kimi-thinking-reasoning-fill.d.ts +12 -0
  8. package/dist/conversion/compat/actions/iflow-kimi-thinking-reasoning-fill.js +67 -0
  9. package/dist/conversion/compat/actions/iflow-response-body-unwrap.d.ts +9 -0
  10. package/dist/conversion/compat/actions/iflow-response-body-unwrap.js +140 -0
  11. package/dist/conversion/compat/actions/lmstudio-responses-fc-ids.d.ts +10 -0
  12. package/dist/conversion/compat/actions/lmstudio-responses-fc-ids.js +59 -0
  13. package/dist/conversion/compat/actions/lmstudio-responses-input-stringify.d.ts +14 -0
  14. package/dist/conversion/compat/actions/lmstudio-responses-input-stringify.js +125 -0
  15. package/dist/conversion/compat/actions/normalize-tool-call-ids.d.ts +11 -0
  16. package/dist/conversion/compat/actions/normalize-tool-call-ids.js +140 -0
  17. package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.d.ts +2 -0
  18. package/dist/conversion/compat/actions/strip-orphan-function-calls-tag.js +152 -0
  19. package/dist/conversion/compat/antigravity-session-signature.d.ts +1 -1
  20. package/dist/conversion/compat/antigravity-session-signature.js +5 -4
  21. package/dist/conversion/compat/profiles/chat-iflow.json +6 -0
  22. package/dist/conversion/compat/profiles/chat-lmstudio.json +7 -1
  23. package/dist/conversion/hub/operation-table/operation-table-runner.js +1 -1
  24. package/dist/conversion/hub/operation-table/semantic-mappers/gemini-mapper.js +19 -2
  25. package/dist/conversion/hub/pipeline/compat/compat-pipeline-executor.js +101 -5
  26. package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.d.ts +2 -0
  27. package/dist/conversion/hub/pipeline/compat/compat-profile-resolver.js +63 -0
  28. package/dist/conversion/hub/pipeline/compat/compat-types.d.ts +18 -0
  29. package/dist/conversion/hub/pipeline/hub-pipeline.js +1 -1
  30. package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage1_semantic_map/index.js +8 -5
  31. package/dist/conversion/hub/pipeline/stages/req_outbound/req_outbound_stage3_compat/index.js +5 -1
  32. package/dist/conversion/hub/pipeline/stages/resp_inbound/resp_inbound_stage1_sse_decode/index.js +113 -0
  33. package/dist/conversion/hub/pipeline/target-utils.js +3 -0
  34. package/dist/conversion/hub/response/provider-response.js +27 -1
  35. package/dist/conversion/responses/responses-openai-bridge.js +32 -6
  36. package/dist/conversion/shared/anthropic-message-utils.js +20 -5
  37. package/dist/conversion/shared/bridge-id-utils.d.ts +2 -0
  38. package/dist/conversion/shared/bridge-id-utils.js +52 -15
  39. package/dist/conversion/shared/responses-conversation-store.js +40 -5
  40. package/dist/conversion/shared/responses-output-builder.js +23 -7
  41. package/dist/conversion/shared/responses-tool-utils.d.ts +1 -0
  42. package/dist/conversion/shared/responses-tool-utils.js +30 -13
  43. package/dist/conversion/shared/text-markup-normalizer.d.ts +1 -0
  44. package/dist/conversion/shared/text-markup-normalizer.js +269 -1
  45. package/dist/router/virtual-router/bootstrap.js +31 -7
  46. package/dist/router/virtual-router/classifier.js +1 -1
  47. package/dist/router/virtual-router/engine/antigravity/alias-lease.d.ts +33 -0
  48. package/dist/router/virtual-router/engine/antigravity/alias-lease.js +247 -0
  49. package/dist/router/virtual-router/engine/health/index.d.ts +23 -0
  50. package/dist/router/virtual-router/engine/health/index.js +720 -0
  51. package/dist/router/virtual-router/engine/provider-key/parse.d.ts +6 -0
  52. package/dist/router/virtual-router/engine/provider-key/parse.js +43 -0
  53. package/dist/router/virtual-router/engine/routing-pools/index.d.ts +13 -0
  54. package/dist/router/virtual-router/engine/routing-pools/index.js +225 -0
  55. package/dist/router/virtual-router/engine/routing-state/keys.d.ts +3 -0
  56. package/dist/router/virtual-router/engine/routing-state/keys.js +30 -0
  57. package/dist/router/virtual-router/engine/routing-state/metadata.d.ts +6 -0
  58. package/dist/router/virtual-router/engine/routing-state/metadata.js +132 -0
  59. package/dist/router/virtual-router/engine/routing-state/store.d.ts +11 -0
  60. package/dist/router/virtual-router/engine/routing-state/store.js +107 -0
  61. package/dist/router/virtual-router/engine-health.d.ts +1 -23
  62. package/dist/router/virtual-router/engine-health.js +1 -720
  63. package/dist/router/virtual-router/engine-selection/route-utils.js +57 -0
  64. package/dist/router/virtual-router/engine-selection/tier-selection-select.js +8 -48
  65. package/dist/router/virtual-router/engine-selection/tier-selection.js +34 -17
  66. package/dist/router/virtual-router/engine-selection.d.ts +1 -13
  67. package/dist/router/virtual-router/engine-selection.js +1 -225
  68. package/dist/router/virtual-router/engine.d.ts +2 -23
  69. package/dist/router/virtual-router/engine.js +130 -603
  70. package/dist/router/virtual-router/message-utils.js +15 -5
  71. package/dist/servertool/engine.js +4 -4
  72. package/dist/servertool/handlers/followup-request-builder.js +46 -0
  73. package/dist/servertool/handlers/gemini-empty-reply-continue.js +48 -47
  74. package/dist/servertool/handlers/stop-message-auto.js +64 -7
  75. package/dist/servertool/handlers/vision.js +10 -0
  76. package/dist/servertool/types.d.ts +3 -0
  77. package/dist/sse/sse-to-json/builders/response-builder.js +6 -0
  78. package/dist/sse/sse-to-json/chat-sse-to-json-converter.js +32 -2
  79. package/dist/sse/sse-to-json/parsers/sse-parser.js +34 -0
  80. package/dist/sse/sse-to-json/responses-sse-to-json-converter.d.ts +1 -0
  81. package/dist/sse/sse-to-json/responses-sse-to-json-converter.js +33 -1
  82. package/dist/tools/apply-patch/args-normalizer/default-actions.d.ts +2 -0
  83. package/dist/tools/apply-patch/args-normalizer/default-actions.js +12 -0
  84. package/dist/tools/apply-patch/args-normalizer/extract-patch.d.ts +2 -0
  85. package/dist/tools/apply-patch/args-normalizer/extract-patch.js +15 -0
  86. package/dist/tools/apply-patch/args-normalizer/index.d.ts +2 -0
  87. package/dist/tools/apply-patch/args-normalizer/index.js +164 -0
  88. package/dist/tools/apply-patch/args-normalizer/structured-builders.d.ts +7 -0
  89. package/dist/tools/apply-patch/args-normalizer/structured-builders.js +85 -0
  90. package/dist/tools/apply-patch/args-normalizer/types.d.ts +54 -0
  91. package/dist/tools/apply-patch/args-normalizer/types.js +1 -0
  92. package/dist/tools/apply-patch/patch-text/looks-like-patch.js +1 -0
  93. package/dist/tools/apply-patch/patch-text/normalize.js +104 -5
  94. package/dist/tools/apply-patch/structured/coercion.js +28 -4
  95. package/dist/tools/apply-patch/validator.js +7 -146
  96. package/package.json +1 -1
@@ -59,8 +59,8 @@ export function detectExtendedThinkingKeyword(text) {
59
59
  export function detectImageAttachment(message) {
60
60
  if (!message)
61
61
  return false;
62
- // 仅基于标准 Chat 语义判断是否携带图片:
63
- // - content 为数组时查找 { type: 'image' | 'image_url' | 'input_image', ... } 块;
62
+ // 仅基于标准 Chat 语义判断是否携带视觉媒体(图片/视频):
63
+ // - content 为数组时查找 { type: 'image' | 'image_url' | 'input_image' | 'video' | 'video_url' | 'input_video', ... } 块;
64
64
  // - 不再依赖 metadata.attachments,也不再用纯文本关键字或剪贴板标记作为信号。
65
65
  if (Array.isArray(message.content)) {
66
66
  for (const part of message.content) {
@@ -69,19 +69,29 @@ export function detectImageAttachment(message) {
69
69
  }
70
70
  const record = part;
71
71
  const typeValue = typeof record.type === 'string' ? record.type.toLowerCase() : '';
72
- // For chat/standardized content, images may appear as:
72
+ // For chat/standardized content, media may appear as:
73
73
  // - { type: "image_url", image_url: { url } }
74
74
  // - { type: "image", uri: "...", data: "...", url: "..." }
75
75
  // - { type: "input_image", image_url: "data:..." }
76
- // Treat any non-empty URL/URI/data on an image-* block as a signal.
76
+ // - { type: "video_url", video_url: { url } }
77
+ // - { type: "video", uri: "...", data: "...", url: "..." }
78
+ // - { type: "input_video", video_url: "data:..." }
79
+ // Treat any non-empty URL/URI/data on a media-* block as a signal.
77
80
  let imageCandidate = '';
78
81
  if (typeof record.image_url === 'string') {
79
82
  imageCandidate = record.image_url ?? '';
80
83
  }
84
+ else if (typeof record.video_url === 'string') {
85
+ imageCandidate = record.video_url ?? '';
86
+ }
81
87
  else if (record.image_url &&
82
88
  typeof record.image_url?.url === 'string') {
83
89
  imageCandidate = record.image_url?.url ?? '';
84
90
  }
91
+ else if (record.video_url &&
92
+ typeof record.video_url?.url === 'string') {
93
+ imageCandidate = record.video_url?.url ?? '';
94
+ }
85
95
  else if (typeof record.url === 'string') {
86
96
  imageCandidate = record.url ?? '';
87
97
  }
@@ -91,7 +101,7 @@ export function detectImageAttachment(message) {
91
101
  else if (typeof record.data === 'string') {
92
102
  imageCandidate = record.data ?? '';
93
103
  }
94
- if (typeValue.includes('image') && imageCandidate.trim().length > 0) {
104
+ if ((typeValue.includes('image') || typeValue.includes('video')) && imageCandidate.trim().length > 0) {
95
105
  return true;
96
106
  }
97
107
  }
@@ -101,7 +101,7 @@ function isEmptyClientResponsePayload(payload) {
101
101
  return false;
102
102
  }
103
103
  // OpenAI Responses: requires_action (function_call output) is a meaningful response and must not be
104
- // treated as "empty". Some auto-followup servertools (stop_message_flow / gemini_empty_reply_continue)
104
+ // treated as "empty". Some auto-followup servertools (stop_message_flow / empty_reply_continue)
105
105
  // previously misclassified this as empty because there is no output_text/content yet.
106
106
  const requiredAction = payload.required_action;
107
107
  if (requiredAction && typeof requiredAction === 'object') {
@@ -337,13 +337,13 @@ export async function runServerToolOrchestration(options) {
337
337
  };
338
338
  }
339
339
  const isStopMessageFlow = engineResult.execution.flowId === 'stop_message_flow';
340
- const isGeminiEmptyReplyContinue = engineResult.execution.flowId === 'gemini_empty_reply_continue';
340
+ const isEmptyReplyContinue = engineResult.execution.flowId === 'empty_reply_continue';
341
341
  const isApplyPatchGuard = engineResult.execution.flowId === 'apply_patch_guard';
342
342
  const isExecCommandGuard = engineResult.execution.flowId === 'exec_command_guard';
343
343
  const stopMessageSource = isStopMessageFlow ? getStopMessageSource(options.adapterContext) : undefined;
344
344
  const isAutoStopMessage = isStopMessageFlow && stopMessageSource !== 'explicit';
345
345
  const isErrorAutoFlow = engineResult.execution.flowId === 'iflow_model_error_retry';
346
- const applyAutoLimit = isAutoStopMessage || isErrorAutoFlow || isGeminiEmptyReplyContinue || isApplyPatchGuard || isExecCommandGuard;
346
+ const applyAutoLimit = isAutoStopMessage || isErrorAutoFlow || isEmptyReplyContinue || isApplyPatchGuard || isExecCommandGuard;
347
347
  // ServerTool followups must not inherit or inject any routeHint; always route fresh.
348
348
  const preserveRouteHint = false;
349
349
  const followupPlan = engineResult.execution.followup;
@@ -432,7 +432,7 @@ export async function runServerToolOrchestration(options) {
432
432
  metadata.__shadowCompareForcedProviderKey = providerKey;
433
433
  }
434
434
  }
435
- const retryEmptyFollowupOnce = isStopMessageFlow || isGeminiEmptyReplyContinue;
435
+ const retryEmptyFollowupOnce = isStopMessageFlow || isEmptyReplyContinue;
436
436
  const maxAttempts = retryEmptyFollowupOnce ? 2 : 1;
437
437
  const followupRequestId = buildFollowupRequestId(options.requestId, engineResult.execution.followup.requestIdSuffix);
438
438
  let followupPayload = coerceFollowupPayloadStream(followupPayloadRaw, metadata.stream === true);
@@ -249,6 +249,45 @@ function injectSystemTextIntoMessages(source, text) {
249
249
  messages.splice(insertAt, 0, sys);
250
250
  return messages;
251
251
  }
252
+ function compactToolContentValue(value, maxChars) {
253
+ const text = typeof value === 'string'
254
+ ? value
255
+ : (() => {
256
+ try {
257
+ return JSON.stringify(value ?? '');
258
+ }
259
+ catch {
260
+ return String(value ?? '');
261
+ }
262
+ })();
263
+ if (text.length <= maxChars) {
264
+ return text;
265
+ }
266
+ const keepHead = Math.max(24, Math.floor(maxChars * 0.45));
267
+ const keepTail = Math.max(24, Math.floor(maxChars * 0.35));
268
+ const omitted = Math.max(0, text.length - keepHead - keepTail);
269
+ const head = text.slice(0, keepHead);
270
+ const tail = text.slice(text.length - keepTail);
271
+ return head + '\n...[tool_output_compacted omitted=' + String(omitted) + ']...\n' + tail;
272
+ }
273
+ function compactToolContentInMessages(source, options) {
274
+ const maxChars = Number.isFinite(options.maxChars) ? Math.max(64, Math.floor(options.maxChars)) : 1200;
275
+ const messages = Array.isArray(source) ? cloneJson(source) : [];
276
+ for (const message of messages) {
277
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
278
+ continue;
279
+ }
280
+ const role = typeof message.role === 'string'
281
+ ? String(message.role).trim().toLowerCase()
282
+ : '';
283
+ if (role !== 'tool') {
284
+ continue;
285
+ }
286
+ const content = message.content;
287
+ message.content = compactToolContentValue(content, maxChars);
288
+ }
289
+ return messages;
290
+ }
252
291
  function buildStandardFollowupTools() {
253
292
  // Keep this list minimal and stable. Used only as a best-effort fallback when a followup hop
254
293
  // would otherwise have no tools at all (which can cause tool-based clients to "break" mid-session).
@@ -372,6 +411,13 @@ export function buildServerToolFollowupChatPayloadFromInjection(args) {
372
411
  messages = trimOpenAiMessagesForFollowup(messages, { maxNonSystemMessages });
373
412
  continue;
374
413
  }
414
+ if (op.op === 'compact_tool_content') {
415
+ const maxChars = typeof op.maxChars === 'number'
416
+ ? Math.max(64, Math.floor(op.maxChars))
417
+ : 1200;
418
+ messages = compactToolContentInMessages(messages, { maxChars });
419
+ continue;
420
+ }
375
421
  if (op.op === 'append_assistant_message') {
376
422
  const required = op.required !== false;
377
423
  const msg = extractAssistantMessageFromChatLike(args.chatResponse);
@@ -2,13 +2,13 @@ import { registerServerToolHandler } from '../registry.js';
2
2
  import { isCompactionRequest } from './compaction-detect.js';
3
3
  import { extractCapturedChatSeed } from './followup-request-builder.js';
4
4
  import { ensureRuntimeMetadata, readRuntimeMetadata } from '../../conversion/shared/runtime-metadata.js';
5
- const FLOW_ID = 'gemini_empty_reply_continue';
5
+ const FLOW_ID = 'empty_reply_continue';
6
+ const MAX_TOOL_HINTS = 24;
6
7
  const handler = async (ctx) => {
7
8
  if (!ctx.capabilities.reenterPipeline) {
8
9
  return null;
9
10
  }
10
11
  // 避免在 followup 请求里再次触发,防止循环。
11
- const adapterRecord = ctx.adapterContext;
12
12
  const rt = readRuntimeMetadata(ctx.adapterContext);
13
13
  const followupRaw = rt?.serverToolFollowup;
14
14
  if (followupRaw === true || (typeof followupRaw === 'string' && followupRaw.trim().toLowerCase() === 'true')) {
@@ -17,22 +17,11 @@ const handler = async (ctx) => {
17
17
  if (hasCompactionFlag(rt)) {
18
18
  return null;
19
19
  }
20
- // 仅针对 gemini-chat 协议 + antigravity.* providerKey 的 /v1/responses 路径启用。
21
- if (ctx.providerProtocol !== 'gemini-chat') {
22
- return null;
23
- }
20
+ // 通用空回复续跑:只要是 /v1/responses 链路且满足空回复判定,不区分 provider family / protocol。
24
21
  const entryEndpoint = (ctx.entryEndpoint || '').toLowerCase();
25
22
  if (!entryEndpoint.includes('/v1/responses')) {
26
23
  return null;
27
24
  }
28
- const providerKey = typeof adapterRecord.providerKey === 'string' && adapterRecord.providerKey.trim()
29
- ? adapterRecord.providerKey.trim().toLowerCase()
30
- : '';
31
- const isAntigravity = providerKey.startsWith('antigravity.');
32
- const isGeminiCli = providerKey.startsWith('gemini-cli.');
33
- if (!isAntigravity && !isGeminiCli) {
34
- return null;
35
- }
36
25
  // 支持两种客户端协议形状:
37
26
  // - OpenAI Chat: choices[0].message.content
38
27
  // - OpenAI Responses: output/output_text/status
@@ -42,7 +31,7 @@ const handler = async (ctx) => {
42
31
  return null;
43
32
  }
44
33
  // 统计连续空回复次数,超过上限后不再自动续写,而是返回一个可重试错误。
45
- const previousCountRaw = rt?.geminiEmptyReplyCount;
34
+ const previousCountRaw = rt?.emptyReplyContinueCount ?? rt?.geminiEmptyReplyCount;
46
35
  const previousCount = typeof previousCountRaw === 'number' && Number.isFinite(previousCountRaw) && previousCountRaw >= 0
47
36
  ? previousCountRaw
48
37
  : 0;
@@ -58,6 +47,7 @@ const handler = async (ctx) => {
58
47
  if (!seed) {
59
48
  return null;
60
49
  }
50
+ const continueText = buildContinueUserText(seed);
61
51
  // 超过最多 3 次空回复:返回一个 HTTP_HANDLER_ERROR 形状的错误,交由上层错误中心处理。
62
52
  if (nextCount > 3) {
63
53
  const errorChat = {
@@ -65,7 +55,7 @@ const handler = async (ctx) => {
65
55
  object: base.object,
66
56
  model: base.model,
67
57
  error: {
68
- message: 'fetch failed: gemini_empty_reply_continue exceeded max empty replies',
58
+ message: 'fetch failed: empty_reply_continue exceeded max empty replies',
69
59
  code: 'HTTP_HANDLER_ERROR',
70
60
  type: 'servertool_empty_reply'
71
61
  }
@@ -80,7 +70,6 @@ const handler = async (ctx) => {
80
70
  })
81
71
  };
82
72
  }
83
- const assistantMessage = extractAssistantMessageForFollowup(ctx.base);
84
73
  return {
85
74
  flowId: FLOW_ID,
86
75
  finalize: async () => ({
@@ -92,13 +81,16 @@ const handler = async (ctx) => {
92
81
  entryEndpoint: ctx.entryEndpoint,
93
82
  injection: {
94
83
  ops: [
84
+ { op: 'preserve_tools' },
95
85
  { op: 'append_assistant_message', required: false },
96
- { op: 'append_user_text', text: '继续执行' }
86
+ { op: 'append_user_text', text: continueText }
97
87
  ]
98
88
  },
99
89
  metadata: (() => {
100
90
  const meta = {};
101
91
  const runtime = ensureRuntimeMetadata(meta);
92
+ runtime.emptyReplyContinueCount = nextCount;
93
+ // Backward compatibility for old snapshots/guards.
102
94
  runtime.geminiEmptyReplyCount = nextCount;
103
95
  return meta;
104
96
  })()
@@ -107,7 +99,7 @@ const handler = async (ctx) => {
107
99
  })
108
100
  };
109
101
  };
110
- registerServerToolHandler('gemini_empty_reply_continue', handler, { trigger: 'auto' });
102
+ registerServerToolHandler('empty_reply_continue', handler, { trigger: 'auto' });
111
103
  function decideEmptyReply(base) {
112
104
  // 1) OpenAI Chat shape
113
105
  const choices = Array.isArray(base.choices) ? base.choices : [];
@@ -164,34 +156,6 @@ function decideEmptyReply(base) {
164
156
  // 允许 output 为空或仅包含空消息:视作空回复,触发自动续写。
165
157
  return { shouldTrigger: true };
166
158
  }
167
- function extractAssistantMessageForFollowup(chatResponse) {
168
- if (!chatResponse || typeof chatResponse !== 'object' || Array.isArray(chatResponse)) {
169
- return null;
170
- }
171
- const choices = Array.isArray(chatResponse.choices)
172
- ? chatResponse.choices
173
- : [];
174
- if (!choices.length) {
175
- return null;
176
- }
177
- const first = choices[0];
178
- if (!first || typeof first !== 'object' || Array.isArray(first)) {
179
- return null;
180
- }
181
- const message = first.message;
182
- if (!message || typeof message !== 'object' || Array.isArray(message)) {
183
- return null;
184
- }
185
- const role = typeof message.role === 'string' ? String(message.role) : '';
186
- if (role && role.toLowerCase() !== 'assistant') {
187
- return null;
188
- }
189
- const content = message.content;
190
- if (typeof content !== 'string' || !content.trim()) {
191
- return null;
192
- }
193
- return { role: 'assistant', content: content.trim() };
194
- }
195
159
  function extractResponsesOutputText(base) {
196
160
  const raw = base.output_text;
197
161
  if (typeof raw === 'string') {
@@ -265,3 +229,40 @@ function hasCompactionFlag(rt) {
265
229
  }
266
230
  return false;
267
231
  }
232
+ function buildContinueUserText(seed) {
233
+ const toolNames = collectToolNames(seed);
234
+ if (!toolNames.length) {
235
+ return '继续执行';
236
+ }
237
+ const shown = toolNames.slice(0, MAX_TOOL_HINTS);
238
+ const omitted = toolNames.length - shown.length;
239
+ const listText = shown.join(', ');
240
+ const tail = omitted > 0 ? `(其余 ${omitted} 个省略)` : '';
241
+ return `继续执行。可用工具列表:${listText}${tail}。请优先调用这些工具,不要返回空回复。`;
242
+ }
243
+ function collectToolNames(seed) {
244
+ const tools = Array.isArray(seed.tools) ? seed.tools : [];
245
+ if (!tools.length) {
246
+ return [];
247
+ }
248
+ const names = [];
249
+ const seen = new Set();
250
+ for (const tool of tools) {
251
+ if (!tool || typeof tool !== 'object' || Array.isArray(tool))
252
+ continue;
253
+ const fnNode = tool.function;
254
+ const fnName = fnNode && typeof fnNode === 'object' && !Array.isArray(fnNode)
255
+ ? normalizeToolName(fnNode.name)
256
+ : '';
257
+ const fallbackName = normalizeToolName(tool.name);
258
+ const resolvedName = fnName || fallbackName;
259
+ if (!resolvedName || seen.has(resolvedName))
260
+ continue;
261
+ seen.add(resolvedName);
262
+ names.push(resolvedName);
263
+ }
264
+ return names;
265
+ }
266
+ function normalizeToolName(value) {
267
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : '';
268
+ }
@@ -166,6 +166,20 @@ const handler = async (ctx) => {
166
166
  state.stopMessageUpdatedAt = now;
167
167
  }
168
168
  saveRoutingInstructionStateAsync(stickyKey, state);
169
+ const followupProviderKey = resolveStopMessageFollowupProviderKey({ record, runtimeMetadata: rt });
170
+ const followupToolContentMaxChars = resolveStopMessageFollowupToolContentMaxChars({
171
+ providerKey: followupProviderKey,
172
+ model: seed.model
173
+ });
174
+ const followupOps = [];
175
+ if (typeof followupToolContentMaxChars === 'number' &&
176
+ Number.isFinite(followupToolContentMaxChars) &&
177
+ followupToolContentMaxChars > 0) {
178
+ followupOps.push({ op: 'compact_tool_content', maxChars: Math.floor(followupToolContentMaxChars) });
179
+ }
180
+ followupOps.push({ op: 'append_assistant_message', required: false });
181
+ followupOps.push({ op: 'ensure_standard_tools' });
182
+ followupOps.push({ op: 'append_user_text', text });
169
183
  return {
170
184
  flowId: FLOW_ID,
171
185
  finalize: async () => ({
@@ -176,11 +190,7 @@ const handler = async (ctx) => {
176
190
  requestIdSuffix: ':stop_followup',
177
191
  entryEndpoint,
178
192
  injection: {
179
- ops: [
180
- { op: 'append_assistant_message', required: false },
181
- { op: 'ensure_standard_tools' },
182
- { op: 'append_user_text', text }
183
- ]
193
+ ops: followupOps
184
194
  },
185
195
  metadata: (connectionState ? { clientConnectionState: connectionState } : {})
186
196
  }
@@ -202,6 +212,53 @@ function resolveStickyKey(record) {
202
212
  }
203
213
  return undefined;
204
214
  }
215
+ function toNonEmptyText(value) {
216
+ return typeof value === 'string' && value.trim().length ? value.trim() : '';
217
+ }
218
+ function readProviderKeyFromMetadata(value) {
219
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
220
+ return '';
221
+ }
222
+ const metadata = value;
223
+ const direct = toNonEmptyText(metadata.providerKey) ||
224
+ toNonEmptyText(metadata.providerId) ||
225
+ toNonEmptyText(metadata.targetProviderKey);
226
+ if (direct) {
227
+ return direct;
228
+ }
229
+ const target = metadata.target;
230
+ if (target && typeof target === 'object' && !Array.isArray(target)) {
231
+ const targetRecord = target;
232
+ return toNonEmptyText(targetRecord.providerKey) || toNonEmptyText(targetRecord.providerId);
233
+ }
234
+ return '';
235
+ }
236
+ function resolveStopMessageFollowupProviderKey(args) {
237
+ const direct = toNonEmptyText(args.record.providerKey) ||
238
+ toNonEmptyText(args.record.providerId) ||
239
+ readProviderKeyFromMetadata(args.record.metadata) ||
240
+ readProviderKeyFromMetadata(args.runtimeMetadata);
241
+ return direct;
242
+ }
243
+ function resolveStopMessageFollowupToolContentMaxChars(params) {
244
+ const raw = String(process.env.ROUTECODEX_STOPMESSAGE_FOLLOWUP_TOOL_CONTENT_MAX_CHARS || '').trim();
245
+ if (raw) {
246
+ const parsed = Number(raw);
247
+ if (Number.isFinite(parsed) && parsed > 0) {
248
+ return Math.max(64, Math.floor(parsed));
249
+ }
250
+ return undefined;
251
+ }
252
+ const providerKey = typeof params.providerKey === 'string' ? params.providerKey.trim().toLowerCase() : '';
253
+ if (providerKey.startsWith('iflow.')) {
254
+ return 1200;
255
+ }
256
+ const model = typeof params.model === 'string' ? params.model.trim().toLowerCase() : '';
257
+ if (model === 'kimi-k2.5' || model.startsWith('kimi-k2.5-')) {
258
+ return 1200;
259
+ }
260
+ return undefined;
261
+ }
205
262
  function isStopFinishReason(base) {
206
263
  if (!base || typeof base !== 'object' || Array.isArray(base)) {
207
264
  return false;
@@ -425,7 +482,7 @@ function resolveImplicitGeminiStopMessageSnapshot(ctx, record) {
425
482
  return null;
426
483
  }
427
484
  // 仅在“空回复”时触发隐式 stopMessage:
428
- // - 这个场景由 gemini_empty_reply_continue 专门处理;
485
+ // - 这个场景由 empty_reply_continue 专门处理;
429
486
  // - stop_message_auto 里的隐式逻辑只作为兼容兜底(且默认关闭),避免对正常 stop 响应追加“继续执行”。
430
487
  if (!isEmptyAssistantReply(ctx.base)) {
431
488
  return null;
@@ -456,7 +513,7 @@ function isEmptyAssistantReply(base) {
456
513
  const finishReason = typeof finishReasonRaw === 'string' && finishReasonRaw.trim()
457
514
  ? finishReasonRaw.trim().toLowerCase()
458
515
  : '';
459
- // 仅接受 stop:length 截断通常并非“空回复”,而是需要续写(由 gemini_empty_reply_continue 负责)。
516
+ // 仅接受 stop:length 截断通常并非“空回复”,而是需要续写(由 empty_reply_continue 负责)。
460
517
  if (finishReason !== 'stop') {
461
518
  return false;
462
519
  }
@@ -103,6 +103,11 @@ function shouldRunVisionFlow(ctx) {
103
103
  }
104
104
  const providerType = typeof record.providerType === 'string' ? record.providerType.toLowerCase() : '';
105
105
  const providerProtocol = typeof record.providerProtocol === 'string' ? record.providerProtocol.toLowerCase() : '';
106
+ const modelId = typeof record.modelId === 'string'
107
+ ? record.modelId.trim().toLowerCase()
108
+ : typeof record.assignedModelId === 'string'
109
+ ? record.assignedModelId.trim().toLowerCase()
110
+ : '';
106
111
  const inlineMultimodal = providerType === 'gemini' ||
107
112
  providerType === 'responses' ||
108
113
  providerProtocol === 'gemini-chat' ||
@@ -110,6 +115,11 @@ function shouldRunVisionFlow(ctx) {
110
115
  if (inlineMultimodal) {
111
116
  return false;
112
117
  }
118
+ // Kimi K2.5 supports inline multimodal natively (image_url/video_url).
119
+ // When the routed model is kimi-k2.5, do not trigger the legacy vision detour.
120
+ if (modelId === 'kimi-k2.5') {
121
+ return false;
122
+ }
113
123
  return true;
114
124
  }
115
125
  function getCapturedRequest(adapterContext) {
@@ -74,6 +74,9 @@ export type ServerToolFollowupInjectionOp = {
74
74
  } | {
75
75
  op: 'trim_openai_messages';
76
76
  maxNonSystemMessages: number;
77
+ } | {
78
+ op: 'compact_tool_content';
79
+ maxChars: number;
77
80
  };
78
81
  export type ServerToolFollowupInjectionPlan = {
79
82
  ops: ServerToolFollowupInjectionOp[];
@@ -712,6 +712,12 @@ export class ResponsesResponseBuilder {
712
712
  break;
713
713
  }
714
714
  case 'function_call':
715
+ // Terminated SSE salvage may end before `function_call.done` / `output_item.done`.
716
+ // In that case the builder state remains `in_progress` and arguments are usually partial/empty.
717
+ // Do not promote such incomplete tool calls into a completed output item.
718
+ if (state.status !== 'completed') {
719
+ continue;
720
+ }
715
721
  outputItem = this.buildFunctionCallItem(state);
716
722
  break;
717
723
  case 'reasoning':
@@ -109,16 +109,46 @@ export class ChatSseToJsonConverter {
109
109
  */
110
110
  parseSseChunk(chunk) {
111
111
  const lines = chunk.trim().split('\n');
112
- let eventType;
112
+ let rawEventType;
113
113
  let dataValue = '';
114
114
  for (const line of lines) {
115
115
  if (line.startsWith('event:')) {
116
- eventType = line.substring(6).trim();
116
+ rawEventType = line.substring(6).trim();
117
117
  }
118
118
  else if (line.startsWith('data:')) {
119
119
  dataValue = line.substring(5).trim();
120
120
  }
121
121
  }
122
+ const normalizeEventType = (candidate) => {
123
+ if (!candidate)
124
+ return undefined;
125
+ const v = candidate.trim();
126
+ if (!v)
127
+ return undefined;
128
+ // OpenAI Chat Completions SSE does not include `event:` lines; we infer types elsewhere.
129
+ // When upstream does include `event:`, accept common aliases for compatibility.
130
+ if (v === 'chat_chunk' || v === 'chat_chunk'.toLowerCase())
131
+ return 'chat_chunk';
132
+ if (v === 'chat.done' || v === 'chat_done')
133
+ return 'chat.done';
134
+ if (v === 'ping' || v === 'heartbeat')
135
+ return 'ping';
136
+ if (v === 'error')
137
+ return 'error';
138
+ // Legacy aliases
139
+ if (v === 'chunk')
140
+ return 'chat_chunk';
141
+ if (v === 'done')
142
+ return 'chat.done';
143
+ return undefined;
144
+ };
145
+ let eventType = normalizeEventType(rawEventType);
146
+ if (!eventType) {
147
+ // OpenAI-compatible streams often omit `event:`; use `[DONE]` sentinel to mark completion.
148
+ if (dataValue) {
149
+ eventType = dataValue === '[DONE]' ? 'chat.done' : 'chat_chunk';
150
+ }
151
+ }
122
152
  if (!eventType) {
123
153
  throw ErrorUtils.createError('SSE event type is required', CHAT_CONVERSION_ERROR_CODES.VALIDATION_ERROR, { chunk });
124
154
  }
@@ -144,6 +144,36 @@ function validateEventType(eventType, config) {
144
144
  }
145
145
  return config.allowedEventTypes.has(eventType);
146
146
  }
147
+ function inferEventTypeFromData(rawEvent, config) {
148
+ // LM Studio (and some other OpenAI-compatible servers) may omit the SSE `event:` line and only
149
+ // send JSON payloads like: `data: {"type":"response.output_item.added", ...}`.
150
+ // Per SSE spec the default event type becomes "message", but for our protocol converters we
151
+ // need the real OpenAI event type to pass strict validation and builder logic.
152
+ if (rawEvent.event && rawEvent.event !== 'message') {
153
+ return null;
154
+ }
155
+ if (!rawEvent.data) {
156
+ return null;
157
+ }
158
+ try {
159
+ const parsed = safeJsonParse(rawEvent.data);
160
+ if (!parsed || typeof parsed !== 'object') {
161
+ return null;
162
+ }
163
+ const candidate = parsed.type;
164
+ if (typeof candidate !== 'string' || !candidate.trim()) {
165
+ return null;
166
+ }
167
+ const normalized = candidate.trim();
168
+ if (!validateEventType(normalized, config)) {
169
+ return null;
170
+ }
171
+ return normalized;
172
+ }
173
+ catch {
174
+ return null;
175
+ }
176
+ }
147
177
  /**
148
178
  * 创建基础事件对象
149
179
  */
@@ -279,6 +309,10 @@ export function parseSseEvent(sseText, config = DEFAULT_SSE_PARSER_CONFIG) {
279
309
  result.error = 'Invalid SSE event format';
280
310
  return result;
281
311
  }
312
+ const inferred = inferEventTypeFromData(rawEvent, config);
313
+ if (inferred) {
314
+ rawEvent.event = inferred;
315
+ }
282
316
  // 验证事件大小
283
317
  if (config.enableStrictValidation && sseText.length > config.maxEventSize) {
284
318
  result.error = `Event size ${sseText.length} exceeds maximum ${config.maxEventSize}`;
@@ -15,6 +15,7 @@ export declare class ResponsesSseToJsonConverterRefactored {
15
15
  * 将SSE流转换为Responses响应
16
16
  */
17
17
  convertSseToJson(sseStream: ResponsesSseEventStream, options: SseToResponsesJsonOptions): Promise<ResponsesResponse>;
18
+ private isTerminatedError;
18
19
  /**
19
20
  * 创建可读流
20
21
  */
@@ -31,6 +31,7 @@ export class ResponsesSseToJsonConverterRefactored {
31
31
  // 1. 创建上下文
32
32
  const context = this.createContext(options);
33
33
  this.contexts.set(options.requestId, context);
34
+ let responseBuilder = null;
34
35
  try {
35
36
  // 2. 创建解析器
36
37
  const parser = createSseParser({
@@ -38,7 +39,7 @@ export class ResponsesSseToJsonConverterRefactored {
38
39
  enableEventRecovery: !this.config.strictMode
39
40
  });
40
41
  // 3. 创建响应构建器
41
- const responseBuilder = createResponseBuilder({
42
+ responseBuilder = createResponseBuilder({
42
43
  enableStrictValidation: false,
43
44
  enableEventRecovery: !this.config.strictMode,
44
45
  maxOutputItems: 50,
@@ -100,6 +101,25 @@ export class ResponsesSseToJsonConverterRefactored {
100
101
  return result.response;
101
102
  }
102
103
  catch (error) {
104
+ // 容错:部分 OpenAI-compatible 上游(例如 LM Studio)在产出 tool_call 后会直接断开 SSE 连接,
105
+ // undici 会抛出 "terminated"。若此时已聚合出可用 response,则应优先返回而不是把它当作致命错误。
106
+ if (responseBuilder && this.isTerminatedError(error)) {
107
+ try {
108
+ const salvaged = responseBuilder.getResult();
109
+ if (salvaged.success && salvaged.response) {
110
+ context.isCompleted = true;
111
+ context.endTime = Date.now();
112
+ context.duration = context.endTime - context.startTime;
113
+ if (options.onCompletion) {
114
+ options.onCompletion(salvaged.response);
115
+ }
116
+ return salvaged.response;
117
+ }
118
+ }
119
+ catch {
120
+ // ignore salvage failure, fall through to normal error path
121
+ }
122
+ }
103
123
  context.isCompleted = true;
104
124
  context.endTime = Date.now();
105
125
  context.duration = context.endTime - context.startTime;
@@ -114,6 +134,18 @@ export class ResponsesSseToJsonConverterRefactored {
114
134
  this.clearContext(options.requestId);
115
135
  }
116
136
  }
137
+ isTerminatedError(error) {
138
+ if (!error || typeof error !== 'object')
139
+ return false;
140
+ const msg = error.message;
141
+ if (typeof msg !== 'string') {
142
+ return false;
143
+ }
144
+ const normalized = msg.toLowerCase();
145
+ return (normalized.includes('terminated') ||
146
+ normalized.includes('upstream_stream_idle_timeout') ||
147
+ normalized.includes('upstream_stream_timeout'));
148
+ }
117
149
  /**
118
150
  * 创建可读流
119
151
  */
@@ -0,0 +1,2 @@
1
+ import type { ApplyPatchNormalizeAction } from './types.js';
2
+ export declare const DEFAULT_APPLY_PATCH_NORMALIZE_ACTIONS: ApplyPatchNormalizeAction[];
@@ -0,0 +1,12 @@
1
+ export const DEFAULT_APPLY_PATCH_NORMALIZE_ACTIONS = [
2
+ { action: 'raw_non_json_patch' },
3
+ { action: 'json_container_patch_fallback' },
4
+ { action: 'record_text_fields', fields: ['patch'] },
5
+ { action: 'record_conflict_patch', patchField: 'patch', fileFields: ['file', 'path', 'filepath', 'filename'] },
6
+ { action: 'record_text_fields', fields: ['diff', 'patchText', 'body', 'input', 'instructions'] },
7
+ { action: 'record_raw_envelope', field: '_raw', parseJson: true, maxDepth: 3 },
8
+ { action: 'record_structured_payload' },
9
+ { action: 'array_structured_payload' },
10
+ { action: 'raw_string_patch' },
11
+ { action: 'invalid_json_guard' }
12
+ ];
@@ -0,0 +1,2 @@
1
+ import type { ApplyPatchExtraction } from './types.js';
2
+ export declare function extractNormalizedPatch(value: string | undefined | null): ApplyPatchExtraction;