plugin-custom-llm 1.1.1 → 1.2.1

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
@@ -0,0 +1,8 @@
1
+ import { Plugin } from '@nocobase/client';
2
+ export declare class PluginCustomLLMClient extends Plugin {
3
+ afterAdd(): Promise<void>;
4
+ beforeLoad(): Promise<void>;
5
+ load(): Promise<void>;
6
+ private get aiPlugin();
7
+ }
8
+ export default PluginCustomLLMClient;
@@ -1,10 +1 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
1
  !function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("react"),require("@nocobase/plugin-ai/client"),require("@nocobase/client"),require("@nocobase/utils/client"),require("antd"),require("react-i18next")):"function"==typeof define&&define.amd?define("plugin-custom-llm",["react","@nocobase/plugin-ai/client","@nocobase/client","@nocobase/utils/client","antd","react-i18next"],t):"object"==typeof exports?exports["plugin-custom-llm"]=t(require("react"),require("@nocobase/plugin-ai/client"),require("@nocobase/client"),require("@nocobase/utils/client"),require("antd"),require("react-i18next")):e["plugin-custom-llm"]=t(e.react,e["@nocobase/plugin-ai/client"],e["@nocobase/client"],e["@nocobase/utils/client"],e.antd,e["react-i18next"])}(self,function(e,t,n,o,r,i){return function(){"use strict";var a={772:function(e){e.exports=n},645:function(e){e.exports=t},584:function(e){e.exports=o},721:function(e){e.exports=r},156:function(t){t.exports=e},238:function(e){e.exports=i}},c={};function u(e){var t=c[e];if(void 0!==t)return t.exports;var n=c[e]={exports:{}};return a[e](n,n.exports,u),n.exports}u.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return u.d(t,{a:t}),t},u.d=function(e,t){for(var n in t)u.o(t,n)&&!u.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},u.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},u.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})};var l={};return!function(){u.r(l),u.d(l,{PluginCustomLLMClient:function(){return g},default:function(){return S}});var e=u(772),t=u(156),n=u.n(t),o=u(584),r=u(238),i="@nocobase/plugin-custom-llm",a=u(721),c=u(645),p=function(){var t=(0,r.useTranslation)(i,{nsMode:"fallback"}).t;return n().createElement("div",{style:{marginBottom:24}},n().createElement(a.Collapse,{bordered:!1,size:"small",items:[{key:"options",label:t("Options"),forceRender:!0,children:n().createElement(e.SchemaComponent,{schema:{type:"void",name:"custom-llm",properties:{temperature:{title:(0,o.tval)("Temperature",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber",default:.7,"x-component-props":{step:.1,min:0,max:2}},maxCompletionTokens:{title:(0,o.tval)("Max completion tokens",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber",default:-1},topP:{title:(0,o.tval)("Top P",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber",default:1,"x-component-props":{step:.1,min:0,max:1}},frequencyPenalty:{title:(0,o.tval)("Frequency penalty",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber",default:0,"x-component-props":{step:.1,min:-2,max:2}},presencePenalty:{title:(0,o.tval)("Presence penalty",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber",default:0,"x-component-props":{step:.1,min:-2,max:2}},responseFormat:{title:(0,o.tval)("Response format",{ns:i}),type:"string","x-decorator":"FormItem","x-component":"Select",enum:[{label:t("Text"),value:"text"},{label:t("JSON"),value:"json_object"}],default:"text"},timeout:{title:(0,o.tval)("Timeout (ms)",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber",default:6e4},maxRetries:{title:(0,o.tval)("Max retries",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber",default:1}}}})}]}))},s={components:{ProviderSettingsForm:function(){return n().createElement(e.SchemaComponent,{schema:{type:"void",properties:{apiKey:{title:(0,o.tval)("API Key",{ns:i}),type:"string",required:!0,"x-decorator":"FormItem","x-component":"TextAreaWithGlobalScope"},disableStream:{title:(0,o.tval)("Disable streaming",{ns:i}),type:"boolean","x-decorator":"FormItem","x-component":"Checkbox","x-content":(0,o.tval)("Disable streaming description",{ns:i})},streamKeepAlive:{title:(0,o.tval)("Stream keepalive",{ns:i}),type:"boolean","x-decorator":"FormItem","x-component":"Checkbox","x-content":(0,o.tval)("Stream keepalive description",{ns:i})},keepAliveIntervalMs:{title:(0,o.tval)("Keepalive interval (ms)",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber","x-component-props":{placeholder:"5000",min:1e3,step:1e3,style:{width:"100%"}},description:(0,o.tval)("Keepalive interval description",{ns:i})},keepAliveContent:{title:(0,o.tval)("Keepalive content",{ns:i}),type:"string","x-decorator":"FormItem","x-component":"Input","x-component-props":{placeholder:"..."},description:(0,o.tval)("Keepalive content description",{ns:i})},timeout:{title:(0,o.tval)("Timeout (ms)",{ns:i}),type:"number","x-decorator":"FormItem","x-component":"InputNumber","x-component-props":{placeholder:"120000",min:0,step:1e3,style:{width:"100%"}},description:(0,o.tval)("Timeout description",{ns:i})},requestConfig:{title:(0,o.tval)("Request config (JSON)",{ns:i}),type:"string","x-decorator":"FormItem","x-component":"Input.TextArea","x-component-props":{placeholder:JSON.stringify({extraHeaders:{},extraBody:{},modelKwargs:{}},null,2),rows:6,style:{fontFamily:"monospace",fontSize:12}},description:(0,o.tval)("Request config description",{ns:i})},responseConfig:{title:(0,o.tval)("Response config (JSON)",{ns:i}),type:"string","x-decorator":"FormItem","x-component":"Input.TextArea","x-component-props":{placeholder:JSON.stringify({contentPath:"auto",reasoningKey:"reasoning_content",responseMapping:{content:"message.response"}},null,2),rows:8,style:{fontFamily:"monospace",fontSize:12}},description:(0,o.tval)("Response config description",{ns:i})}}}})},ModelSettingsForm:function(){return n().createElement(e.SchemaComponent,{components:{Options:p,ModelSelect:c.ModelSelect},schema:{type:"void",properties:{model:{title:(0,o.tval)("Model",{ns:i}),type:"string",required:!0,"x-decorator":"FormItem","x-component":"ModelSelect"},options:{type:"void","x-component":"Options"}}}})}}};function m(e,t,n,o,r,i,a){try{var c=e[i](a),u=c.value}catch(e){n(e);return}c.done?t(u):Promise.resolve(u).then(o,r)}function f(e){return function(){var t=this,n=arguments;return new Promise(function(o,r){var i=e.apply(t,n);function a(e){m(i,o,r,a,c,"next",e)}function c(e){m(i,o,r,a,c,"throw",e)}a(void 0)})}}function d(e,t,n){return(d=x()?Reflect.construct:function(e,t,n){var o=[null];o.push.apply(o,t);var r=new(Function.bind.apply(e,o));return n&&b(r,n.prototype),r}).apply(null,arguments)}function y(e){return(y=Object.setPrototypeOf?Object.getPrototypeOf:function(e){return e.__proto__||Object.getPrototypeOf(e)})(e)}function b(e,t){return(b=Object.setPrototypeOf||function(e,t){return e.__proto__=t,e})(e,t)}function v(e){var t="function"==typeof Map?new Map:void 0;return(v=function(e){if(null===e||-1===Function.toString.call(e).indexOf("[native code]"))return e;if("function"!=typeof e)throw TypeError("Super expression must either be null or a function");if(void 0!==t){if(t.has(e))return t.get(e);t.set(e,n)}function n(){return d(e,arguments,y(this).constructor)}return n.prototype=Object.create(e.prototype,{constructor:{value:n,enumerable:!1,writable:!0,configurable:!0}}),b(n,e)})(e)}function x(){try{var e=!Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],function(){}))}catch(e){}return(x=function(){return!!e})()}function h(e,t){var n,o,r,i,a={label:0,sent:function(){if(1&r[0])throw r[1];return r[1]},trys:[],ops:[]};return i={next:c(0),throw:c(1),return:c(2)},"function"==typeof Symbol&&(i[Symbol.iterator]=function(){return this}),i;function c(i){return function(c){var u=[i,c];if(n)throw TypeError("Generator is already executing.");for(;a;)try{if(n=1,o&&(r=2&u[0]?o.return:u[0]?o.throw||((r=o.return)&&r.call(o),0):o.next)&&!(r=r.call(o,u[1])).done)return r;switch(o=0,r&&(u=[2&u[0],r.value]),u[0]){case 0:case 1:r=u;break;case 4:return a.label++,{value:u[1],done:!1};case 5:a.label++,o=u[1],u=[0];continue;case 7:u=a.ops.pop(),a.trys.pop();continue;default:if(!(r=(r=a.trys).length>0&&r[r.length-1])&&(6===u[0]||2===u[0])){a=0;continue}if(3===u[0]&&(!r||u[1]>r[0]&&u[1]<r[3])){a.label=u[1];break}if(6===u[0]&&a.label<r[1]){a.label=r[1],r=u;break}if(r&&a.label<r[2]){a.label=r[2],a.ops.push(u);break}r[2]&&a.ops.pop(),a.trys.pop();continue}u=t.call(e,a)}catch(e){u=[6,e],o=0}finally{n=r=0}if(5&u[0])throw u[1];return{value:u[0]?u[1]:void 0,done:!0}}}}var g=function(e){var t;if("function"!=typeof e&&null!==e)throw TypeError("Super expression must either be null or a function");function n(){var e,t;if(!(this instanceof n))throw TypeError("Cannot call a class as a function");return e=n,t=arguments,e=y(e),function(e,t){var n;if(t&&("object"==((n=t)&&"undefined"!=typeof Symbol&&n.constructor===Symbol?"symbol":typeof n)||"function"==typeof t))return t;if(void 0===e)throw ReferenceError("this hasn't been initialised - super() hasn't been called");return e}(this,x()?Reflect.construct(e,t||[],y(this).constructor):e.apply(this,t))}return n.prototype=Object.create(e&&e.prototype,{constructor:{value:n,writable:!0,configurable:!0}}),e&&b(n,e),t=[{key:"afterAdd",value:function(){return f(function(){return h(this,function(e){return[2]})})()}},{key:"beforeLoad",value:function(){return f(function(){return h(this,function(e){return[2]})})()}},{key:"load",value:function(){var e=this;return f(function(){return h(this,function(t){return e.aiPlugin.aiManager.registerLLMProvider("custom-llm",s),[2]})})()}},{key:"aiPlugin",get:function(){return this.app.pm.get("ai")}}],function(e,t){for(var n=0;n<t.length;n++){var o=t[n];o.enumerable=o.enumerable||!1,o.configurable=!0,"value"in o&&(o.writable=!0),Object.defineProperty(e,o.key,o)}}(n.prototype,t),n}(v(e.Plugin)),S=g}(),l}()});
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const ModelSettingsForm: React.FC;
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const ProviderSettingsForm: React.FC;
@@ -0,0 +1,2 @@
1
+ import { LLMProviderOptions } from '@nocobase/plugin-ai/client';
2
+ export declare const customLLMProviderOptions: LLMProviderOptions;
@@ -0,0 +1,2 @@
1
+ export declare const namespace = "@nocobase/plugin-custom-llm";
2
+ export declare function useT(): import("react-i18next").TFunction<"@nocobase/plugin-custom-llm", undefined>;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+ declare const _default: Record<string, ModelConstructor>;
10
+ export default _default;
@@ -0,0 +1,5 @@
1
+ import { Plugin } from '@nocobase/client';
2
+ export declare class PluginCustomLlmClient extends Plugin {
3
+ load(): Promise<void>;
4
+ }
5
+ export default PluginCustomLlmClient;
@@ -1,20 +1,13 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
1
  module.exports = {
11
- "@nocobase/client": "2.0.15",
12
- "@nocobase/plugin-ai": "2.0.15",
2
+ "@nocobase/client": "2.0.20",
3
+ "@nocobase/plugin-ai": "2.0.20",
13
4
  "react-i18next": "11.18.6",
14
- "@nocobase/server": "2.0.15",
15
- "@nocobase/flow-engine": "2.0.15",
16
- "@nocobase/database": "2.0.15",
17
- "react": "18.2.0",
18
- "@nocobase/utils": "2.0.15",
5
+ "@nocobase/server": "2.0.20",
6
+ "@nocobase/flow-engine": "2.0.20",
7
+ "@nocobase/database": "2.0.20",
8
+ "axios": "1.14.0",
9
+ "@nocobase/actions": "2.0.20",
10
+ "react": "18.3.1",
11
+ "@nocobase/utils": "2.0.20",
19
12
  "antd": "5.24.2"
20
13
  };
@@ -0,0 +1,2 @@
1
+ export * from './server';
2
+ export { default } from './server';
package/dist/index.js CHANGED
@@ -1,12 +1,3 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
1
  var __create = Object.create;
11
2
  var __defProp = Object.defineProperty;
12
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -0,0 +1 @@
1
+ export { default } from './plugin';
@@ -1,12 +1,3 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
1
  var __create = Object.create;
11
2
  var __defProp = Object.defineProperty;
12
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -0,0 +1,54 @@
1
+ import { LLMProvider, LLMProviderMeta } from '@nocobase/plugin-ai';
2
+ import { Model } from '@nocobase/database';
3
+ import { Context } from '@nocobase/actions';
4
+ import type { ParsedAttachmentResult } from '@nocobase/plugin-ai';
5
+ export declare class CustomLLMProvider extends LLMProvider {
6
+ get baseURL(): any;
7
+ private get requestConfig();
8
+ private get responseConfig();
9
+ createModel(): any;
10
+ parseResponseChunk(chunk: any): string | null;
11
+ parseResponseMessage(message: Model): {
12
+ key: Model;
13
+ content: Record<string, any>;
14
+ role: Model;
15
+ };
16
+ parseReasoningContent(chunk: any): {
17
+ status: string;
18
+ content: string;
19
+ } | null;
20
+ /**
21
+ * Extract response metadata from LLM output for post-save enrichment.
22
+ * Sanitizes overly long message IDs from Gemini or other providers.
23
+ */
24
+ parseResponseMetadata(output: any): any;
25
+ parseResponseError(err: any): any;
26
+ /**
27
+ * Self-contained file reading that correctly handles the APP_PUBLIC_PATH prefix.
28
+ *
29
+ * plugin-ai's encodeLocalFile does path.join(cwd, url) without stripping
30
+ * APP_PUBLIC_PATH, so when the app is deployed under a sub-path (e.g. /my-app)
31
+ * the resolved path becomes '{cwd}/my-app/storage/uploads/…' which does not exist.
32
+ * We cannot fix that in plugin-ai (core), so we re-implement file reading here
33
+ * with the prefix stripped before the cwd join.
34
+ */
35
+ private readFileAsBase64;
36
+ /**
37
+ * Override parseAttachment to convert all attachments into formats that
38
+ * generic OpenAI-compatible endpoints actually support:
39
+ *
40
+ * - Images → image_url block with base64 data URI (vision models)
41
+ * - Text files → text block with decoded UTF-8 content
42
+ * - Binary → text block with base64 data URI (multi-modal or fallback)
43
+ *
44
+ * The base-class implementation returns a LangChain ContentBlock.Multimodal.File
45
+ * (`type: 'file'`) for non-image attachments. LangChain serialises this as the
46
+ * newer OpenAI Files API format which most custom/local endpoints do NOT understand,
47
+ * causing file content to be silently dropped.
48
+ *
49
+ * This method is entirely self-contained — it does not call super — so it is
50
+ * safe to use without modifying plugin-ai core.
51
+ */
52
+ parseAttachment(ctx: Context, attachment: any): Promise<ParsedAttachmentResult>;
53
+ }
54
+ export declare const customLLMProviderOptions: LLMProviderMeta;
@@ -1,12 +1,3 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
1
  var __create = Object.create;
11
2
  var __defProp = Object.defineProperty;
12
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -42,6 +33,8 @@ __export(custom_llm_exports, {
42
33
  module.exports = __toCommonJS(custom_llm_exports);
43
34
  var import_plugin_ai = require("@nocobase/plugin-ai");
44
35
  var import_node_path = __toESM(require("node:path"));
36
+ var import_promises = __toESM(require("node:fs/promises"));
37
+ var import_axios = __toESM(require("axios"));
45
38
  const KEEPALIVE_PREFIX = "\u200B\u200B\u200B";
46
39
  function requireFromApp(moduleName) {
47
40
  const appNodeModules = process.env.NODE_MODULES_PATH || import_node_path.default.join(process.cwd(), "node_modules");
@@ -100,11 +93,41 @@ function extractTextContent(content, contentPath) {
100
93
  }
101
94
  return "";
102
95
  }
103
- function safeParseJSON(str) {
96
+ function isTextMimetype(mimetype) {
97
+ if (!mimetype) return false;
98
+ if (mimetype.startsWith("text/")) return true;
99
+ const TEXT_APPLICATION_TYPES = /* @__PURE__ */ new Set([
100
+ "application/json",
101
+ "application/xml",
102
+ "application/xhtml+xml",
103
+ "application/atom+xml",
104
+ "application/rss+xml",
105
+ "application/csv",
106
+ "application/javascript",
107
+ "application/typescript",
108
+ "application/x-javascript",
109
+ "application/x-typescript",
110
+ "application/x-yaml",
111
+ "application/yaml",
112
+ "application/x-json",
113
+ "application/geo+json",
114
+ "application/ld+json",
115
+ "application/manifest+json",
116
+ "application/graphql",
117
+ "application/x-www-form-urlencoded",
118
+ "application/toml",
119
+ "application/x-sh",
120
+ "application/x-shellscript",
121
+ "application/sql"
122
+ ]);
123
+ return TEXT_APPLICATION_TYPES.has(mimetype);
124
+ }
125
+ function safeParseJSON(str, fieldName) {
104
126
  if (!str || typeof str !== "string") return {};
105
127
  try {
106
128
  return JSON.parse(str);
107
- } catch {
129
+ } catch (e) {
130
+ console.warn(`[CustomLLM] Failed to parse ${fieldName || "JSON config"}: ${e.message}`);
108
131
  return {};
109
132
  }
110
133
  }
@@ -170,13 +193,13 @@ function createMappingFetch(responseMapping) {
170
193
 
171
194
  `));
172
195
  } else {
173
- controller.enqueue(encoder.encode(line + "\n"));
196
+ controller.enqueue(encoder.encode(line + "\n\n"));
174
197
  }
175
198
  } catch {
176
- controller.enqueue(encoder.encode(line + "\n"));
199
+ controller.enqueue(encoder.encode(line + "\n\n"));
177
200
  }
178
201
  } else {
179
- controller.enqueue(encoder.encode(line + "\n"));
202
+ controller.enqueue(encoder.encode(line + "\n\n"));
180
203
  }
181
204
  }
182
205
  }
@@ -236,9 +259,16 @@ function wrapWithStreamKeepAlive(model, options) {
236
259
  let streamDone = false;
237
260
  let streamError = null;
238
261
  let notifyReady = null;
262
+ let hasToolCallChunks = false;
263
+ let hasErrored = false;
239
264
  const consumer = (async () => {
265
+ var _a, _b;
240
266
  try {
241
267
  for await (const chunk of baseIterator) {
268
+ const msg = chunk == null ? void 0 : chunk.message;
269
+ 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)) {
270
+ hasToolCallChunks = true;
271
+ }
242
272
  buffer.push(chunk);
243
273
  if (notifyReady) {
244
274
  notifyReady();
@@ -247,6 +277,11 @@ function wrapWithStreamKeepAlive(model, options) {
247
277
  }
248
278
  } catch (err) {
249
279
  streamError = err;
280
+ hasErrored = true;
281
+ if (notifyReady) {
282
+ notifyReady();
283
+ notifyReady = null;
284
+ }
250
285
  } finally {
251
286
  streamDone = true;
252
287
  if (notifyReady) {
@@ -260,6 +295,7 @@ function wrapWithStreamKeepAlive(model, options) {
260
295
  while (buffer.length > 0) {
261
296
  yield buffer.shift();
262
297
  }
298
+ hasToolCallChunks = false;
263
299
  if (streamDone) break;
264
300
  const waitForChunk = new Promise((resolve) => {
265
301
  notifyReady = resolve;
@@ -273,9 +309,14 @@ function wrapWithStreamKeepAlive(model, options) {
273
309
  ]);
274
310
  if (timer) clearTimeout(timer);
275
311
  if (result === "timeout" && !streamDone && buffer.length === 0) {
312
+ if (streamError || hasErrored) break;
313
+ if (hasToolCallChunks) continue;
276
314
  const keepAliveChunk = new ChatGenerationChunk({
277
- message: new AIMessageChunk({ content: KEEPALIVE_PREFIX + keepAliveContent }),
278
- text: KEEPALIVE_PREFIX + keepAliveContent
315
+ message: new AIMessageChunk({
316
+ content: KEEPALIVE_PREFIX,
317
+ additional_kwargs: { __keepalive: true }
318
+ }),
319
+ text: KEEPALIVE_PREFIX
279
320
  });
280
321
  yield keepAliveChunk;
281
322
  }
@@ -296,37 +337,51 @@ function fixEmptyToolProperties(model) {
296
337
  var _a;
297
338
  const originalBind = (_a = model.bindTools) == null ? void 0 : _a.bind(model);
298
339
  if (!originalBind) return model;
340
+ const PLACEHOLDER_PROP = {
341
+ _placeholder: { type: "string", description: "No parameters required" }
342
+ };
343
+ function fixPropertiesInSchema(schema) {
344
+ if (!schema || typeof schema !== "object") return;
345
+ if (schema.properties && typeof schema.properties === "object" && Object.keys(schema.properties).length === 0) {
346
+ schema.properties = { ...PLACEHOLDER_PROP };
347
+ }
348
+ for (const key of ["anyOf", "oneOf", "allOf"]) {
349
+ if (Array.isArray(schema[key])) {
350
+ schema[key].forEach((sub) => fixPropertiesInSchema(sub));
351
+ }
352
+ }
353
+ }
299
354
  model.bindTools = function(tools, kwargs) {
355
+ var _a2;
300
356
  const fixedTools = tools.map((tool) => {
301
- var _a2, _b;
357
+ var _a3, _b;
302
358
  if (!tool || typeof tool !== "object") return tool;
359
+ if (typeof ((_a3 = tool.schema) == null ? void 0 : _a3.safeParse) === "function") {
360
+ return tool;
361
+ }
303
362
  const schema = tool.schema;
304
- if (schema && typeof schema === "object") {
305
- const props = schema.properties || (schema == null ? void 0 : schema.shape);
363
+ if (schema && typeof schema === "object" && !schema.safeParse) {
364
+ const props = schema.properties;
306
365
  if (props && typeof props === "object" && Object.keys(props).length === 0) {
307
366
  return {
308
367
  ...tool,
309
368
  schema: {
310
369
  ...schema,
311
- properties: {
312
- _placeholder: { type: "string", description: "No parameters required" }
313
- }
370
+ properties: { ...PLACEHOLDER_PROP }
314
371
  }
315
372
  };
316
373
  }
317
374
  }
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) {
375
+ const funcParams = (_b = tool.function) == null ? void 0 : _b.parameters;
376
+ if (funcParams == null ? void 0 : funcParams.properties) {
377
+ if (typeof funcParams.properties === "object" && Object.keys(funcParams.properties).length === 0) {
321
378
  return {
322
379
  ...tool,
323
380
  function: {
324
381
  ...tool.function,
325
382
  parameters: {
326
- ...params,
327
- properties: {
328
- _placeholder: { type: "string", description: "No parameters required" }
329
- }
383
+ ...funcParams,
384
+ properties: { ...PLACEHOLDER_PROP }
330
385
  }
331
386
  }
332
387
  };
@@ -334,7 +389,22 @@ function fixEmptyToolProperties(model) {
334
389
  }
335
390
  return tool;
336
391
  });
337
- return originalBind(fixedTools, kwargs);
392
+ const result = originalBind(fixedTools, kwargs);
393
+ try {
394
+ const config = (result == null ? void 0 : result.kwargs) ?? (result == null ? void 0 : result.defaultOptions);
395
+ if ((config == null ? void 0 : config.tools) && Array.isArray(config.tools)) {
396
+ for (const tool of config.tools) {
397
+ if ((_a2 = tool == null ? void 0 : tool.function) == null ? void 0 : _a2.parameters) {
398
+ fixPropertiesInSchema(tool.function.parameters);
399
+ }
400
+ if (tool == null ? void 0 : tool.parameters) {
401
+ fixPropertiesInSchema(tool.parameters);
402
+ }
403
+ }
404
+ }
405
+ } catch {
406
+ }
407
+ return result;
338
408
  };
339
409
  return model;
340
410
  }
@@ -344,11 +414,11 @@ class CustomLLMProvider extends import_plugin_ai.LLMProvider {
344
414
  }
345
415
  get requestConfig() {
346
416
  var _a;
347
- return safeParseJSON((_a = this.serviceOptions) == null ? void 0 : _a.requestConfig);
417
+ return safeParseJSON((_a = this.serviceOptions) == null ? void 0 : _a.requestConfig, "requestConfig");
348
418
  }
349
419
  get responseConfig() {
350
420
  var _a;
351
- return safeParseJSON((_a = this.serviceOptions) == null ? void 0 : _a.responseConfig);
421
+ return safeParseJSON((_a = this.serviceOptions) == null ? void 0 : _a.responseConfig, "responseConfig");
352
422
  }
353
423
  createModel() {
354
424
  var _a;
@@ -404,11 +474,12 @@ class CustomLLMProvider extends import_plugin_ai.LLMProvider {
404
474
  const resConfig = this.responseConfig;
405
475
  const text = extractTextContent(chunk, resConfig.contentPath);
406
476
  if (isKeepAlive(text)) {
407
- return null;
477
+ return KEEPALIVE_PREFIX;
408
478
  }
409
479
  return stripToolCallTags(text);
410
480
  }
411
481
  parseResponseMessage(message) {
482
+ var _a, _b;
412
483
  const { content: rawContent, messageId, metadata, role, toolCalls, attachments, workContext } = message;
413
484
  const content = {
414
485
  ...rawContent ?? {},
@@ -429,6 +500,10 @@ class CustomLLMProvider extends import_plugin_ai.LLMProvider {
429
500
  content.content = content.content.replace(new RegExp(escapedPrefix + ".*?(?=" + escapedPrefix + "|$)", "g"), "");
430
501
  content.content = stripToolCallTags(content.content);
431
502
  }
503
+ if (((_b = (_a = content.metadata) == null ? void 0 : _a.additional_kwargs) == null ? void 0 : _b.__keepalive) !== void 0) {
504
+ const { __keepalive, ...cleanKwargs } = content.metadata.additional_kwargs;
505
+ content.metadata = { ...content.metadata, additional_kwargs: cleanKwargs };
506
+ }
432
507
  return {
433
508
  key: messageId,
434
509
  content,
@@ -436,18 +511,128 @@ class CustomLLMProvider extends import_plugin_ai.LLMProvider {
436
511
  };
437
512
  }
438
513
  parseReasoningContent(chunk) {
439
- var _a;
514
+ var _a, _b, _c;
440
515
  const resConfig = this.responseConfig;
441
516
  const reasoningKey = resConfig.reasoningKey || "reasoning_content";
442
- const reasoning = (_a = chunk == null ? void 0 : chunk.additional_kwargs) == null ? void 0 : _a[reasoningKey];
517
+ 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
518
  if (reasoning && typeof reasoning === "string") {
444
519
  return { status: "streaming", content: reasoning };
445
520
  }
446
521
  return null;
447
522
  }
523
+ /**
524
+ * Extract response metadata from LLM output for post-save enrichment.
525
+ * Sanitizes overly long message IDs from Gemini or other providers.
526
+ */
527
+ parseResponseMetadata(output) {
528
+ var _a, _b;
529
+ try {
530
+ const generation = (_b = (_a = output == null ? void 0 : output.generations) == null ? void 0 : _a[0]) == null ? void 0 : _b[0];
531
+ if (!generation) return [null, null];
532
+ const message = generation.message;
533
+ let id = message == null ? void 0 : message.id;
534
+ if (!id) return [null, null];
535
+ if (typeof id === "string" && id.length > 128) {
536
+ id = id.substring(0, 128);
537
+ }
538
+ const metadata = {};
539
+ if (message == null ? void 0 : message.response_metadata) {
540
+ metadata.finish_reason = message.response_metadata.finish_reason;
541
+ metadata.system_fingerprint = message.response_metadata.system_fingerprint;
542
+ }
543
+ if (message == null ? void 0 : message.usage_metadata) {
544
+ metadata.usage_metadata = message.usage_metadata;
545
+ }
546
+ return Object.keys(metadata).length > 0 ? [id, metadata] : [null, null];
547
+ } catch {
548
+ return [null, null];
549
+ }
550
+ }
448
551
  parseResponseError(err) {
449
552
  return (err == null ? void 0 : err.message) ?? "Unexpected LLM service error";
450
553
  }
554
+ /**
555
+ * Self-contained file reading that correctly handles the APP_PUBLIC_PATH prefix.
556
+ *
557
+ * plugin-ai's encodeLocalFile does path.join(cwd, url) without stripping
558
+ * APP_PUBLIC_PATH, so when the app is deployed under a sub-path (e.g. /my-app)
559
+ * the resolved path becomes '{cwd}/my-app/storage/uploads/…' which does not exist.
560
+ * We cannot fix that in plugin-ai (core), so we re-implement file reading here
561
+ * with the prefix stripped before the cwd join.
562
+ */
563
+ async readFileAsBase64(ctx, attachment) {
564
+ const fileManager = this.app.pm.get("file-manager");
565
+ const rawUrl = await fileManager.getFileURL(attachment);
566
+ const url = decodeURIComponent(rawUrl);
567
+ if (url.startsWith("http://") || url.startsWith("https://")) {
568
+ const referer = ctx.get("referer") || "";
569
+ const ua = ctx.get("user-agent") || "";
570
+ const response = await import_axios.default.get(url, {
571
+ responseType: "arraybuffer",
572
+ // Default 30s timeout to prevent slow file servers from blocking indefinitely
573
+ timeout: 3e4,
574
+ headers: { referer, "User-Agent": ua }
575
+ });
576
+ return Buffer.from(response.data).toString("base64");
577
+ }
578
+ let localPath = url;
579
+ const appPublicPath = (process.env.APP_PUBLIC_PATH || "/").replace(/\/+$/, "");
580
+ if (appPublicPath && localPath.startsWith(appPublicPath + "/")) {
581
+ localPath = localPath.slice(appPublicPath.length);
582
+ }
583
+ const storageRoot = import_node_path.default.resolve(process.cwd());
584
+ const absPath = import_node_path.default.resolve(storageRoot, localPath.replace(/^\//, ""));
585
+ if (!absPath.startsWith(storageRoot + import_node_path.default.sep) && absPath !== storageRoot) {
586
+ throw new Error(`Attachment path escapes storage root: ${localPath}`);
587
+ }
588
+ const data = await import_promises.default.readFile(absPath);
589
+ return Buffer.from(data).toString("base64");
590
+ }
591
+ /**
592
+ * Override parseAttachment to convert all attachments into formats that
593
+ * generic OpenAI-compatible endpoints actually support:
594
+ *
595
+ * - Images → image_url block with base64 data URI (vision models)
596
+ * - Text files → text block with decoded UTF-8 content
597
+ * - Binary → text block with base64 data URI (multi-modal or fallback)
598
+ *
599
+ * The base-class implementation returns a LangChain ContentBlock.Multimodal.File
600
+ * (`type: 'file'`) for non-image attachments. LangChain serialises this as the
601
+ * newer OpenAI Files API format which most custom/local endpoints do NOT understand,
602
+ * causing file content to be silently dropped.
603
+ *
604
+ * This method is entirely self-contained — it does not call super — so it is
605
+ * safe to use without modifying plugin-ai core.
606
+ */
607
+ async parseAttachment(ctx, attachment) {
608
+ const mimetype = attachment.mimetype || "application/octet-stream";
609
+ const filename = attachment.filename || attachment.name || "file";
610
+ const data = await this.readFileAsBase64(ctx, attachment);
611
+ if (mimetype.startsWith("image/")) {
612
+ return {
613
+ placement: "contentBlocks",
614
+ content: {
615
+ type: "image_url",
616
+ image_url: { url: `data:${mimetype};base64,${data}` }
617
+ }
618
+ };
619
+ }
620
+ let textContent;
621
+ if (isTextMimetype(mimetype)) {
622
+ const decoded = Buffer.from(data, "base64").toString("utf-8");
623
+ textContent = `<attachment filename="${filename}" type="${mimetype}">
624
+ ${decoded}
625
+ </attachment>`;
626
+ } else {
627
+ textContent = `<attachment filename="${filename}" type="${mimetype}">
628
+ data:${mimetype};base64,${data}
629
+ </attachment>`;
630
+ }
631
+ return {
632
+ placement: "contentBlocks",
633
+ content: { type: "text", text: textContent }
634
+ };
635
+ }
451
636
  }
452
637
  const customLLMProviderOptions = {
453
638
  title: "Custom LLM (OpenAI Compatible)",
@@ -0,0 +1,12 @@
1
+ import { Plugin } from '@nocobase/server';
2
+ export declare class PluginCustomLLMServer extends Plugin {
3
+ afterAdd(): Promise<void>;
4
+ beforeLoad(): Promise<void>;
5
+ load(): Promise<void>;
6
+ install(): Promise<void>;
7
+ afterEnable(): Promise<void>;
8
+ afterDisable(): Promise<void>;
9
+ remove(): Promise<void>;
10
+ private get aiPlugin();
11
+ }
12
+ export default PluginCustomLLMServer;
@@ -1,12 +1,3 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
1
  var __defProp = Object.defineProperty;
11
2
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
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.1",
7
7
  "main": "dist/server/index.js",
8
8
  "nocobase": {
9
9
  "supportedVersions": [