plugin-custom-llm 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,7 +7,9 @@ NocoBase plugin for integrating external LLM providers that support OpenAI-compa
7
7
  - **OpenAI-compatible**: Works with any LLM provider exposing `/chat/completions` endpoint
8
8
  - **Auto content detection**: Handles both string and array content blocks (`[{type: 'text', text: '...'}]`)
9
9
  - **Response mapping**: Transform non-standard API responses to OpenAI format via JSON config (supports streaming SSE and JSON)
10
- - **Reasoning content**: Display thinking/reasoning from DeepSeek-compatible providers
10
+ - **Reasoning content**: Display thinking/reasoning from DeepSeek-compatible providers (multi-path detection)
11
+ - **Stream keepalive**: Prevent proxy/gateway timeouts during long model thinking phases
12
+ - **Tool calling support**: Gemini-compatible tool schema fixing (Zod + JSON Schema)
11
13
  - **Configurable**: JSON config editors for request and response customization
12
14
  - **Locale support**: English, Vietnamese, Chinese
13
15
 
@@ -23,6 +25,11 @@ Upload `plugin-custom-llm-x.x.x.tgz` via NocoBase Plugin Manager UI, then enable
23
25
  |---|---|
24
26
  | **Base URL** | LLM endpoint URL, e.g. `https://your-llm-server.com/v1` |
25
27
  | **API Key** | Authentication key |
28
+ | **Disable Streaming** | Disable streaming for models that return empty stream values |
29
+ | **Stream Keep Alive** | Enable keepalive to prevent timeouts during long thinking phases |
30
+ | **Keep Alive Interval** | Interval in ms between keepalive signals (default: 5000) |
31
+ | **Keep Alive Content** | Visual indicator text during keepalive (default: `...`) |
32
+ | **Timeout** | Custom timeout in ms for slow-responding models |
26
33
  | **Request config (JSON)** | Optional. Extra request configuration |
27
34
  | **Response config (JSON)** | Optional. Response parsing and mapping configuration |
28
35
 
@@ -72,6 +79,26 @@ Upload `plugin-custom-llm-x.x.x.tgz` via NocoBase Plugin Manager UI, then enable
72
79
 
73
80
  Standard OpenAI-compatible parameters: temperature, max tokens, top P, frequency/presence penalty, response format, timeout, max retries.
74
81
 
82
+ ## Changelog
83
+
84
+ ### v1.2.0
85
+
86
+ - **Fix**: Keepalive no longer interferes with tool call sequences (prevents tool call corruption)
87
+ - **Fix**: Gemini-compatible tool schema fixing — handles Zod schemas via dual-phase approach (pre/post conversion)
88
+ - **Fix**: Keepalive content no longer contaminates saved messages in DB
89
+ - **Fix**: Response metadata extraction with long ID sanitization (>128 chars truncated)
90
+ - **Fix**: Multi-path reasoning content detection (`additional_kwargs` + `kwargs.additional_kwargs`)
91
+ - **Fix**: Improved error recovery in keepalive consumer (immediate error propagation)
92
+
93
+ ### v1.1.1
94
+
95
+ - Stream keepalive proxy for long thinking phases
96
+ - Response mapping for non-standard LLM APIs
97
+
98
+ ### v1.0.0
99
+
100
+ - Initial release with OpenAI-compatible LLM provider support
101
+
75
102
  ## License
76
103
 
77
104
  Apache-2.0
@@ -8,13 +8,13 @@
8
8
  */
9
9
 
10
10
  module.exports = {
11
- "@nocobase/client": "2.0.15",
12
- "@nocobase/plugin-ai": "2.0.15",
11
+ "@nocobase/client": "2.0.20",
12
+ "@nocobase/plugin-ai": "2.0.20",
13
13
  "react-i18next": "11.18.6",
14
- "@nocobase/server": "2.0.15",
15
- "@nocobase/flow-engine": "2.0.15",
16
- "@nocobase/database": "2.0.15",
14
+ "@nocobase/server": "2.0.20",
15
+ "@nocobase/flow-engine": "2.0.20",
16
+ "@nocobase/database": "2.0.20",
17
17
  "react": "18.2.0",
18
- "@nocobase/utils": "2.0.15",
18
+ "@nocobase/utils": "2.0.20",
19
19
  "antd": "5.24.2"
20
20
  };
@@ -236,9 +236,16 @@ function wrapWithStreamKeepAlive(model, options) {
236
236
  let streamDone = false;
237
237
  let streamError = null;
238
238
  let notifyReady = null;
239
+ let hasToolCallChunks = false;
240
+ let hasErrored = false;
239
241
  const consumer = (async () => {
242
+ var _a, _b;
240
243
  try {
241
244
  for await (const chunk of baseIterator) {
245
+ const msg = chunk == null ? void 0 : chunk.message;
246
+ if (((_a = msg == null ? void 0 : msg.tool_call_chunks) == null ? void 0 : _a.length) || ((_b = msg == null ? void 0 : msg.tool_calls) == null ? void 0 : _b.length)) {
247
+ hasToolCallChunks = true;
248
+ }
242
249
  buffer.push(chunk);
243
250
  if (notifyReady) {
244
251
  notifyReady();
@@ -247,6 +254,11 @@ function wrapWithStreamKeepAlive(model, options) {
247
254
  }
248
255
  } catch (err) {
249
256
  streamError = err;
257
+ hasErrored = true;
258
+ if (notifyReady) {
259
+ notifyReady();
260
+ notifyReady = null;
261
+ }
250
262
  } finally {
251
263
  streamDone = true;
252
264
  if (notifyReady) {
@@ -260,6 +272,7 @@ function wrapWithStreamKeepAlive(model, options) {
260
272
  while (buffer.length > 0) {
261
273
  yield buffer.shift();
262
274
  }
275
+ hasToolCallChunks = false;
263
276
  if (streamDone) break;
264
277
  const waitForChunk = new Promise((resolve) => {
265
278
  notifyReady = resolve;
@@ -273,9 +286,14 @@ function wrapWithStreamKeepAlive(model, options) {
273
286
  ]);
274
287
  if (timer) clearTimeout(timer);
275
288
  if (result === "timeout" && !streamDone && buffer.length === 0) {
289
+ if (streamError || hasErrored) break;
290
+ if (hasToolCallChunks) continue;
276
291
  const keepAliveChunk = new ChatGenerationChunk({
277
- message: new AIMessageChunk({ content: KEEPALIVE_PREFIX + keepAliveContent }),
278
- text: KEEPALIVE_PREFIX + keepAliveContent
292
+ message: new AIMessageChunk({
293
+ content: KEEPALIVE_PREFIX,
294
+ additional_kwargs: { __keepalive: true }
295
+ }),
296
+ text: KEEPALIVE_PREFIX
279
297
  });
280
298
  yield keepAliveChunk;
281
299
  }
@@ -296,37 +314,51 @@ function fixEmptyToolProperties(model) {
296
314
  var _a;
297
315
  const originalBind = (_a = model.bindTools) == null ? void 0 : _a.bind(model);
298
316
  if (!originalBind) return model;
317
+ const PLACEHOLDER_PROP = {
318
+ _placeholder: { type: "string", description: "No parameters required" }
319
+ };
320
+ function fixPropertiesInSchema(schema) {
321
+ if (!schema || typeof schema !== "object") return;
322
+ if (schema.properties && typeof schema.properties === "object" && Object.keys(schema.properties).length === 0) {
323
+ schema.properties = { ...PLACEHOLDER_PROP };
324
+ }
325
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
326
+ if (Array.isArray(schema[key])) {
327
+ schema[key].forEach((sub) => fixPropertiesInSchema(sub));
328
+ }
329
+ }
330
+ }
299
331
  model.bindTools = function(tools, kwargs) {
332
+ var _a2;
300
333
  const fixedTools = tools.map((tool) => {
301
- var _a2, _b;
334
+ var _a3, _b;
302
335
  if (!tool || typeof tool !== "object") return tool;
336
+ if (typeof ((_a3 = tool.schema) == null ? void 0 : _a3.safeParse) === "function") {
337
+ return tool;
338
+ }
303
339
  const schema = tool.schema;
304
- if (schema && typeof schema === "object") {
305
- const props = schema.properties || (schema == null ? void 0 : schema.shape);
340
+ if (schema && typeof schema === "object" && !schema.safeParse) {
341
+ const props = schema.properties;
306
342
  if (props && typeof props === "object" && Object.keys(props).length === 0) {
307
343
  return {
308
344
  ...tool,
309
345
  schema: {
310
346
  ...schema,
311
- properties: {
312
- _placeholder: { type: "string", description: "No parameters required" }
313
- }
347
+ properties: { ...PLACEHOLDER_PROP }
314
348
  }
315
349
  };
316
350
  }
317
351
  }
318
- if ((_b = (_a2 = tool.function) == null ? void 0 : _a2.parameters) == null ? void 0 : _b.properties) {
319
- const params = tool.function.parameters;
320
- if (typeof params.properties === "object" && Object.keys(params.properties).length === 0) {
352
+ const funcParams = (_b = tool.function) == null ? void 0 : _b.parameters;
353
+ if (funcParams == null ? void 0 : funcParams.properties) {
354
+ if (typeof funcParams.properties === "object" && Object.keys(funcParams.properties).length === 0) {
321
355
  return {
322
356
  ...tool,
323
357
  function: {
324
358
  ...tool.function,
325
359
  parameters: {
326
- ...params,
327
- properties: {
328
- _placeholder: { type: "string", description: "No parameters required" }
329
- }
360
+ ...funcParams,
361
+ properties: { ...PLACEHOLDER_PROP }
330
362
  }
331
363
  }
332
364
  };
@@ -334,7 +366,22 @@ function fixEmptyToolProperties(model) {
334
366
  }
335
367
  return tool;
336
368
  });
337
- return originalBind(fixedTools, kwargs);
369
+ const result = originalBind(fixedTools, kwargs);
370
+ try {
371
+ const config = (result == null ? void 0 : result.kwargs) ?? (result == null ? void 0 : result.defaultOptions);
372
+ if ((config == null ? void 0 : config.tools) && Array.isArray(config.tools)) {
373
+ for (const tool of config.tools) {
374
+ if ((_a2 = tool == null ? void 0 : tool.function) == null ? void 0 : _a2.parameters) {
375
+ fixPropertiesInSchema(tool.function.parameters);
376
+ }
377
+ if (tool == null ? void 0 : tool.parameters) {
378
+ fixPropertiesInSchema(tool.parameters);
379
+ }
380
+ }
381
+ }
382
+ } catch {
383
+ }
384
+ return result;
338
385
  };
339
386
  return model;
340
387
  }
@@ -404,11 +451,12 @@ class CustomLLMProvider extends import_plugin_ai.LLMProvider {
404
451
  const resConfig = this.responseConfig;
405
452
  const text = extractTextContent(chunk, resConfig.contentPath);
406
453
  if (isKeepAlive(text)) {
407
- return null;
454
+ return KEEPALIVE_PREFIX;
408
455
  }
409
456
  return stripToolCallTags(text);
410
457
  }
411
458
  parseResponseMessage(message) {
459
+ var _a, _b;
412
460
  const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext } = message;
413
461
  const content = {
414
462
  ...rawContent ?? {},
@@ -429,6 +477,10 @@ class CustomLLMProvider extends import_plugin_ai.LLMProvider {
429
477
  content.content = content.content.replace(new RegExp(escapedPrefix + ".*?(?=" + escapedPrefix + "|$)", "g"), "");
430
478
  content.content = stripToolCallTags(content.content);
431
479
  }
480
+ if (((_b = (_a = content.metadata) == null ? void 0 : _a.additional_kwargs) == null ? void 0 : _b.__keepalive) !== void 0) {
481
+ const { __keepalive, ...cleanKwargs } = content.metadata.additional_kwargs;
482
+ content.metadata = { ...content.metadata, additional_kwargs: cleanKwargs };
483
+ }
432
484
  return {
433
485
  key: messageId,
434
486
  content,
@@ -436,15 +488,43 @@ class CustomLLMProvider extends import_plugin_ai.LLMProvider {
436
488
  };
437
489
  }
438
490
  parseReasoningContent(chunk) {
439
- var _a;
491
+ var _a, _b, _c;
440
492
  const resConfig = this.responseConfig;
441
493
  const reasoningKey = resConfig.reasoningKey || "reasoning_content";
442
- const reasoning = (_a = chunk == null ? void 0 : chunk.additional_kwargs) == null ? void 0 : _a[reasoningKey];
494
+ const reasoning = ((_a = chunk == null ? void 0 : chunk.additional_kwargs) == null ? void 0 : _a[reasoningKey]) ?? ((_c = (_b = chunk == null ? void 0 : chunk.kwargs) == null ? void 0 : _b.additional_kwargs) == null ? void 0 : _c[reasoningKey]);
443
495
  if (reasoning && typeof reasoning === "string") {
444
496
  return { status: "streaming", content: reasoning };
445
497
  }
446
498
  return null;
447
499
  }
500
+ /**
501
+ * Extract response metadata from LLM output for post-save enrichment.
502
+ * Sanitizes overly long message IDs from Gemini or other providers.
503
+ */
504
+ parseResponseMetadata(output) {
505
+ var _a, _b;
506
+ try {
507
+ const generation = (_b = (_a = output == null ? void 0 : output.generations) == null ? void 0 : _a[0]) == null ? void 0 : _b[0];
508
+ if (!generation) return [null, null];
509
+ const message = generation.message;
510
+ let id = message == null ? void 0 : message.id;
511
+ if (!id) return [null, null];
512
+ if (typeof id === "string" && id.length > 128) {
513
+ id = id.substring(0, 128);
514
+ }
515
+ const metadata = {};
516
+ if (message == null ? void 0 : message.response_metadata) {
517
+ metadata.finish_reason = message.response_metadata.finish_reason;
518
+ metadata.system_fingerprint = message.response_metadata.system_fingerprint;
519
+ }
520
+ if (message == null ? void 0 : message.usage_metadata) {
521
+ metadata.usage_metadata = message.usage_metadata;
522
+ }
523
+ return Object.keys(metadata).length > 0 ? [id, metadata] : [null, null];
524
+ } catch {
525
+ return [null, null];
526
+ }
527
+ }
448
528
  parseResponseError(err) {
449
529
  return (err == null ? void 0 : err.message) ?? "Unexpected LLM service error";
450
530
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "displayName": "AI LLM: Custom (OpenAI Compatible)",
4
4
  "displayName.zh-CN": "AI LLM:自定义(OpenAI 兼容)",
5
5
  "description": "OpenAI-compatible LLM provider with auto response format detection for external LLM services.",
6
- "version": "1.1.1",
6
+ "version": "1.2.0",
7
7
  "main": "dist/server/index.js",
8
8
  "nocobase": {
9
9
  "supportedVersions": [