@myassis/gateway 1.0.77 → 1.0.78

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.
@@ -12,7 +12,36 @@ const logger = (0, shared_1.getLogger)('LLMClient');
12
12
  /** 历史工具输出在上下文中的最大保留长度 */
13
13
  const HISTORY_TOOL_OUTPUT_LIMIT = index_js_2.appConfig.oldToolContentLimit;
14
14
  /** 请求体字符硬上限,超过则视为上下文超限 */
15
- const HARD_MAX_CHARS = 200000; /**
15
+ const HARD_MAX_CHARS = 200000;
16
+ /**
17
+ * 规范化工具调用参数。
18
+ *
19
+ * OpenAI 兼容接口要求 arguments 是「合法 JSON 字符串」;传入 null / undefined /
20
+ * 被截断的半截 JSON 都会导致 400 Invalid request body。
21
+ */
22
+ function normalizeArguments(input) {
23
+ if (input == null)
24
+ return '{}';
25
+ if (typeof input === 'object') {
26
+ try {
27
+ return JSON.stringify(input);
28
+ }
29
+ catch {
30
+ return '{}';
31
+ }
32
+ }
33
+ const text = String(input);
34
+ if (!text.trim())
35
+ return '{}';
36
+ try {
37
+ JSON.parse(text);
38
+ return text;
39
+ }
40
+ catch {
41
+ // 非法 JSON(例如历史裁剪产生的半截字符串)降级为带原文的合法对象
42
+ return JSON.stringify({ _raw: text });
43
+ }
44
+ } /**
16
45
  * LLM 调用结果类型
17
46
  */
18
47
  function convertBase64ToImage(base64Img) {
@@ -200,7 +229,7 @@ class LLMClient {
200
229
  return [{
201
230
  role: 'tool',
202
231
  tool_call_id: m.tool_call_id,
203
- content: m.content,
232
+ content: m.content || '(无输出)',
204
233
  }];
205
234
  }
206
235
  }
@@ -213,7 +242,7 @@ class LLMClient {
213
242
  id: tc.id,
214
243
  function: {
215
244
  name: tc.toolName,
216
- arguments: tc.input
245
+ arguments: normalizeArguments(tc.input)
217
246
  },
218
247
  type: 'function'
219
248
  })),
@@ -233,11 +262,13 @@ class LLMClient {
233
262
  const toolCallItem = toolCall.toolCalls[i];
234
263
  if (!toolCallItem?.id)
235
264
  continue;
265
+ if (!toolCallItem.toolName)
266
+ continue;
236
267
  item.tool_calls.push({
237
268
  id: toolCallItem.id,
238
269
  function: {
239
270
  name: toolCallItem.toolName,
240
- arguments: toolCallItem.input
271
+ arguments: normalizeArguments(toolCallItem.input)
241
272
  },
242
273
  type: 'function'
243
274
  });
@@ -66,11 +66,53 @@ function clip(text, limit) {
66
66
  return text;
67
67
  return text.slice(0, limit) + '...(内容过长已截断)';
68
68
  }
69
+ /**
70
+ * 截断工具调用参数,同时保证结果仍是合法 JSON。
71
+ *
72
+ * 不能直接对 arguments 字符串做 substring:截断后会变成
73
+ * `{"path":"a.ts","content":"xxx` 这类非法 JSON,严格校验的厂商
74
+ * (如豆包/Volcengine)会直接返回 400 Invalid request body。
75
+ * 因此这里解析后逐个截断字符串字段,再重新序列化。
76
+ */
77
+ function clipJsonArguments(args, limit) {
78
+ if (typeof args !== 'string')
79
+ return args;
80
+ if (args.length <= limit)
81
+ return args;
82
+ try {
83
+ const parsed = JSON.parse(args);
84
+ const shrink = (value) => {
85
+ if (typeof value === 'string') {
86
+ return value.length > limit ? value.slice(0, limit) + '...(已截断)' : value;
87
+ }
88
+ if (Array.isArray(value))
89
+ return value.map(shrink);
90
+ if (value && typeof value === 'object') {
91
+ const out = {};
92
+ for (const key of Object.keys(value))
93
+ out[key] = shrink(value[key]);
94
+ return out;
95
+ }
96
+ return value;
97
+ };
98
+ return JSON.stringify(shrink(parsed));
99
+ }
100
+ catch {
101
+ // 无法解析(非 JSON 参数)时保持原样,宁可占用上下文也不发出非法请求
102
+ return args;
103
+ }
104
+ }
69
105
  /** 浅拷贝一条消息,保证裁剪不影响原对象 */
70
106
  function cloneMessage(message) {
71
107
  const copy = { ...message };
72
108
  if (Array.isArray(message.tool_calls)) {
73
- copy.tool_calls = message.tool_calls.map((tc) => ({ ...tc }));
109
+ copy.tool_calls = message.tool_calls.map((tc) => (tc?.function ? { ...tc, function: { ...tc.function } } : { ...tc }));
110
+ }
111
+ if (Array.isArray(message.toolCalls)) {
112
+ copy.toolCalls = message.toolCalls.map((tc) => ({
113
+ ...tc,
114
+ toolCalls: Array.isArray(tc?.toolCalls) ? tc.toolCalls.map((x) => ({ ...x })) : tc?.toolCalls,
115
+ }));
74
116
  }
75
117
  if (Array.isArray(message.attachments)) {
76
118
  copy.attachments = message.attachments.map((att) => ({ ...att }));
@@ -112,10 +154,9 @@ function clipPair(messages, pair, limit) {
112
154
  // 工具参数往往是上下文膨胀的主因(写文件 / patch 类调用)
113
155
  if (Array.isArray(head.tool_calls)) {
114
156
  for (const tc of head.tool_calls) {
115
- if (typeof tc.input === 'string')
116
- tc.input = clip(tc.input, limit);
117
- if (tc.function && typeof tc.function.arguments === 'string') {
118
- tc.function.arguments = clip(tc.function.arguments, limit);
157
+ tc.input = clipJsonArguments(tc.input, limit);
158
+ if (tc.function) {
159
+ tc.function.arguments = clipJsonArguments(tc.function.arguments, limit);
119
160
  }
120
161
  }
121
162
  }
@@ -180,12 +221,16 @@ function buildContext(messages, protectFrom, maxChars = index_js_1.appConfig.con
180
221
  }
181
222
  }
182
223
  const dropped = working.filter((_, index) => !dropIndexes.has(index));
183
- // 用一条系统提示替代被丢弃的轮次,避免模型认为自己没做过这些事
184
- dropped.splice(protectFrom, 0, {
185
- role: 'system',
186
- content: `(已省略 ${dropCount} 轮较早的工具调用记录以控制上下文长度)`,
187
- attachments: [],
188
- });
224
+ // 用提示说明被丢弃的轮次,避免模型认为自己没做过这些事。
225
+ // 注意:不能在对话中间插入 system 消息,部分厂商只允许 system 位于首位,
226
+ // 因此把提示并入首条 system 消息(没有则退化为 assistant 提示)。
227
+ const notice = `(已省略 ${dropCount} 轮较早的工具调用记录以控制上下文长度)`;
228
+ if (dropped[0]?.role === 'system' && typeof dropped[0].content === 'string') {
229
+ dropped[0] = { ...dropped[0], content: `${dropped[0].content}\n${notice}` };
230
+ }
231
+ else {
232
+ dropped.splice(protectFrom, 0, { role: 'assistant', content: notice, attachments: [] });
233
+ }
189
234
  working = dropped;
190
235
  }
191
236
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myassis/gateway",
3
- "version": "1.0.77",
3
+ "version": "1.0.78",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {