@aalis/plugin-llm-openai 0.14.1 → 0.16.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/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +210 -150
- package/dist/index.js.map +1 -1
- package/dist/locales/en.d.ts +59 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/en.js +59 -0
- package/dist/locales/en.js.map +1 -0
- package/dist/texts.d.ts +117 -0
- package/dist/texts.d.ts.map +1 -0
- package/dist/texts.js +62 -0
- package/dist/texts.js.map +1 -0
- package/package.json +11 -9
package/dist/index.js
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
|
-
import { LLMCapabilities, llm } from '@aalis/api-llm';
|
|
2
|
-
import { config, definePlugin, lifecycle, logger, provide } from '@aalis/core';
|
|
1
|
+
import { finishReasonOf, IDLE_TIMEOUT_MAX_MS, idleTimeout, LLMCapabilities, llm, metered, requestedThinking, } from '@aalis/api-llm';
|
|
2
|
+
import { config, definePlugin, events, lifecycle, logger, provide } from '@aalis/core';
|
|
3
3
|
import { configError, defineConfig, missingConfigError, parseConfig } from '@aalis/schema-config';
|
|
4
|
+
import { TextError, textClauses, textOf, toSource } from '@aalis/schema-i18n';
|
|
4
5
|
import { prepareLLMMessages, toLLMRole } from '@aalis/schema-message';
|
|
5
6
|
import { truncateChars } from '@aalis/util-text-normalize';
|
|
7
|
+
import { Agent, errors } from 'undici';
|
|
8
|
+
import { t } from './texts.js';
|
|
6
9
|
// ===== 插件元数据 =====
|
|
7
|
-
/** 已知的内容审查错误关键词 */
|
|
8
|
-
const CONTENT_FILTER_PATTERNS = [
|
|
9
|
-
'content exists risk',
|
|
10
|
-
'content_filter',
|
|
11
|
-
'content_policy',
|
|
12
|
-
'sensitive content',
|
|
13
|
-
'risk control',
|
|
14
|
-
];
|
|
15
10
|
/** 错误信息与日志里附带的摘录上限(字符)。网关的 HTML 错误页可达数十 KB;截断按代理对安全,不留孤代理 */
|
|
16
11
|
const ERROR_BODY_MAX_CHARS = 500;
|
|
17
12
|
/** 错误信息与日志里附带的摘录:空白(含 HTML 错误页的换行)折叠成一个空格后截断,不把换行带进错误信息与日志 */
|
|
@@ -19,51 +14,45 @@ function bodyExcerpt(body) {
|
|
|
19
14
|
return truncateChars(body.replace(/\s+/g, ' ').trim(), ERROR_BODY_MAX_CHARS, '…');
|
|
20
15
|
}
|
|
21
16
|
/**
|
|
22
|
-
* 读配置时校验 baseUrl
|
|
23
|
-
*
|
|
17
|
+
* 读配置时校验 baseUrl,解析不了的、带查询串或片段的与带用户名或密码的抛配置错误。插件在 baseUrl 后面拼路径,带查询串或
|
|
18
|
+
* 片段时路径落进查询串或片段,请求地址是坏的;末尾只有一个 ? 时解析后查询串为空,拼出来照样是坏的,所以按原文判。带凭据
|
|
19
|
+
* 的 URL fetch 拒发,凭据还会随报错与条目名称(WebUI 的模型下拉)显示出来;解析不了的无从判断有没有凭据,请求同样发不
|
|
20
|
+
* 出去。消息不带 URL
|
|
24
21
|
*/
|
|
25
22
|
function checkBaseUrl(baseUrl) {
|
|
26
23
|
if (!URL.canParse(baseUrl)) {
|
|
27
|
-
throw configError('
|
|
24
|
+
throw configError(t('baseUrlInvalid'));
|
|
25
|
+
}
|
|
26
|
+
if (/[?#]/.test(baseUrl)) {
|
|
27
|
+
throw configError(t('baseUrlQuery'));
|
|
28
28
|
}
|
|
29
29
|
const { username, password } = new URL(baseUrl);
|
|
30
30
|
if (username || password) {
|
|
31
|
-
throw configError('
|
|
31
|
+
throw configError(t('baseUrlCredentials'));
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*/
|
|
38
|
-
function redactUrl(url) {
|
|
39
|
-
const parsed = new URL(url);
|
|
40
|
-
if (!parsed.search)
|
|
41
|
-
return url;
|
|
42
|
-
parsed.search = '';
|
|
43
|
-
return parsed.href;
|
|
44
|
-
}
|
|
45
|
-
/**
|
|
46
|
-
* 错误消息连同底层原因,写成一行。fetch 网络失败的消息固定是「fetch failed」,真实原因(DNS、拒绝连接、TLS)在
|
|
47
|
-
* cause 上;连 localhost 时两个地址族都失败,cause 是消息为空的 AggregateError,原因在它的子错误上
|
|
35
|
+
* 错误连同底层原因,写成一行(日志取 toSource)。fetch 网络失败的消息固定是「fetch failed」,真实原因(DNS、拒绝连接、TLS)
|
|
36
|
+
* 在 cause 上;连 localhost 时两个地址族都失败,cause 是消息为空的 AggregateError,原因在它的子错误上
|
|
48
37
|
*/
|
|
49
38
|
function describeError(err) {
|
|
50
39
|
const cause = err instanceof Error ? err.cause : undefined;
|
|
51
40
|
const reasons = cause instanceof AggregateError ? cause.errors : cause instanceof Error ? [cause] : [];
|
|
52
41
|
const detail = reasons.map(e => (e instanceof Error ? e.message : String(e))).join('; ');
|
|
53
|
-
return
|
|
42
|
+
return detail ? t('withCause', { error: textOf(err), detail }) : textOf(err);
|
|
54
43
|
}
|
|
55
44
|
/** 常见状态码的一句提示 */
|
|
56
45
|
function statusHint(status) {
|
|
57
46
|
if (status === 401 || status === 403)
|
|
58
|
-
return '
|
|
47
|
+
return t('hintAuth');
|
|
59
48
|
if (status === 402)
|
|
60
|
-
return '
|
|
49
|
+
return t('hintPayment');
|
|
61
50
|
if (status === 404)
|
|
62
|
-
return '
|
|
51
|
+
return t('hintNotFound');
|
|
63
52
|
if (status === 429)
|
|
64
|
-
return '
|
|
53
|
+
return t('hintRateLimit');
|
|
65
54
|
if (status >= 500)
|
|
66
|
-
return '
|
|
55
|
+
return t('hintUpstream');
|
|
67
56
|
return undefined;
|
|
68
57
|
}
|
|
69
58
|
/**
|
|
@@ -89,73 +78,121 @@ function upstreamMessage(body) {
|
|
|
89
78
|
function httpErrorMessage(provider, status, body) {
|
|
90
79
|
const hint = statusHint(status);
|
|
91
80
|
const message = upstreamMessage(body);
|
|
92
|
-
return
|
|
81
|
+
return textClauses([
|
|
82
|
+
hint ? t('apiErrorHint', { provider, status, hint }) : t('apiError', { provider, status }),
|
|
83
|
+
message ? t('upstreamMessage', { message }) : t('seeLogs'),
|
|
84
|
+
]);
|
|
93
85
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
86
|
+
const LOW_TO_HIGH = [
|
|
87
|
+
{ id: 'low', label: t('levelLow') },
|
|
88
|
+
{ id: 'medium', label: t('levelMedium') },
|
|
89
|
+
{ id: 'high', label: t('levelHigh') },
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* reasoning_effort 写法下模型的档位,按模型族给:gpt-5.1 起(gpt-5.<n>)多一档关(none),不带字段时就是关;gpt-5 本身
|
|
93
|
+
* 最浅是最低(minimal);o1-mini 与 o1-preview 会思考却不收这个字段,不声明;其余能思考的模型是低、中、高
|
|
94
|
+
*/
|
|
95
|
+
function effortLevels(model) {
|
|
96
|
+
const lower = model.toLowerCase();
|
|
97
|
+
if (/^o1-(mini|preview)/.test(lower))
|
|
98
|
+
return undefined;
|
|
99
|
+
if (/^gpt-5\.\d/.test(lower))
|
|
100
|
+
return { levels: [{ id: 'none', label: t('levelOff') }, ...LOW_TO_HIGH], default: 'none' };
|
|
101
|
+
if (lower.startsWith('gpt-5')) {
|
|
102
|
+
return { levels: [{ id: 'minimal', label: t('levelMinimal') }, ...LOW_TO_HIGH], default: 'medium' };
|
|
99
103
|
}
|
|
100
|
-
return
|
|
104
|
+
return { levels: LOW_TO_HIGH, default: 'medium' };
|
|
101
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* 思考档位的两种写法(thinkingParam):能思考的模型(能力里有 thinking 的)按实例选的写法声明档位,fields 把档位翻成
|
|
108
|
+
* 请求体字段。请求带了档位才发,没带时不发、按端点自己的默认,默认档写的就是端点的默认
|
|
109
|
+
*/
|
|
110
|
+
const THINKING_STYLES = {
|
|
111
|
+
reasoning_effort: {
|
|
112
|
+
levels: effortLevels,
|
|
113
|
+
fields: level => ({ reasoning_effort: level }),
|
|
114
|
+
},
|
|
115
|
+
thinking: {
|
|
116
|
+
levels: () => ({
|
|
117
|
+
levels: [
|
|
118
|
+
{ id: 'off', label: t('levelOff') },
|
|
119
|
+
{ id: 'on', label: t('levelOn') },
|
|
120
|
+
],
|
|
121
|
+
default: 'on',
|
|
122
|
+
}),
|
|
123
|
+
fields: level => ({ thinking: { type: level === 'on' ? 'enabled' : 'disabled' } }),
|
|
124
|
+
},
|
|
125
|
+
};
|
|
102
126
|
const configSchema = defineConfig({
|
|
103
127
|
apiKey: {
|
|
104
128
|
type: 'string',
|
|
105
129
|
label: 'API Key',
|
|
106
130
|
secret: true,
|
|
107
131
|
onInvalid: 'error',
|
|
108
|
-
description: '
|
|
132
|
+
description: t('apiKeyDescription'),
|
|
109
133
|
},
|
|
110
134
|
baseUrl: {
|
|
111
135
|
type: 'string',
|
|
112
|
-
label: '
|
|
136
|
+
label: t('baseUrlLabel'),
|
|
113
137
|
default: 'https://api.openai.com/v1',
|
|
114
138
|
onInvalid: 'error',
|
|
115
|
-
description: '
|
|
116
|
-
'可替换为任何兼容服务(如 Gemini 的 https://generativelanguage.googleapis.com/v1beta/openai)。',
|
|
139
|
+
description: t('baseUrlDescription'),
|
|
117
140
|
},
|
|
118
141
|
customModels: {
|
|
119
142
|
type: 'textarea',
|
|
120
|
-
label: '
|
|
143
|
+
label: t('customModelsLabel'),
|
|
121
144
|
default: '',
|
|
122
|
-
description: '
|
|
145
|
+
description: t('customModelsDescription'),
|
|
123
146
|
},
|
|
124
147
|
discoverModels: {
|
|
125
148
|
type: 'boolean',
|
|
126
|
-
label: '
|
|
149
|
+
label: t('discoverModelsLabel'),
|
|
127
150
|
default: true,
|
|
128
|
-
description: '
|
|
151
|
+
description: t('discoverModelsDescription'),
|
|
129
152
|
},
|
|
130
153
|
modelCapabilities: {
|
|
131
154
|
type: 'textarea',
|
|
132
|
-
label: '
|
|
155
|
+
label: t('modelCapabilitiesLabel'),
|
|
133
156
|
default: '',
|
|
134
|
-
description: '
|
|
157
|
+
description: t('modelCapabilitiesDescription'),
|
|
135
158
|
},
|
|
136
159
|
providerCapabilities: {
|
|
137
160
|
type: 'string',
|
|
138
|
-
label: '
|
|
161
|
+
label: t('providerCapabilitiesLabel'),
|
|
139
162
|
default: '',
|
|
140
|
-
description: '
|
|
163
|
+
description: t('providerCapabilitiesDescription'),
|
|
141
164
|
},
|
|
142
165
|
timeout: {
|
|
143
166
|
type: 'number',
|
|
144
|
-
label: '
|
|
167
|
+
label: t('timeoutLabel'),
|
|
145
168
|
default: 120,
|
|
146
|
-
description: '
|
|
169
|
+
description: t('timeoutDescription'),
|
|
170
|
+
},
|
|
171
|
+
temperature: { type: 'number', label: t('temperatureLabel'), default: 0.7, description: t('temperatureDescription') },
|
|
172
|
+
maxTokens: {
|
|
173
|
+
type: 'number',
|
|
174
|
+
label: t('maxTokensLabel'),
|
|
175
|
+
default: 4096,
|
|
176
|
+
min: 1,
|
|
177
|
+
integer: true,
|
|
178
|
+
onInvalid: 'error',
|
|
179
|
+
description: t('maxTokensDescription'),
|
|
180
|
+
},
|
|
181
|
+
contextLength: {
|
|
182
|
+
type: 'number',
|
|
183
|
+
label: t('contextLengthLabel'),
|
|
184
|
+
default: 128000,
|
|
185
|
+
description: t('contextLengthDescription'),
|
|
147
186
|
},
|
|
148
|
-
temperature: { type: 'number', label: '温度', default: 0.7, description: '0-2,越高越随机' },
|
|
149
|
-
maxTokens: { type: 'number', label: '最大 Token', default: 4096, description: '单次回复最大生成 token 数' },
|
|
150
|
-
contextLength: { type: 'number', label: '上下文长度', default: 128000, description: '模型上下文窗口大小' },
|
|
151
187
|
thinkingParam: {
|
|
152
|
-
type: '
|
|
153
|
-
label: '
|
|
154
|
-
default:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
'
|
|
158
|
-
|
|
188
|
+
type: 'select',
|
|
189
|
+
label: t('thinkingParamLabel'),
|
|
190
|
+
default: 'reasoning_effort',
|
|
191
|
+
options: [
|
|
192
|
+
{ label: t('thinkingParamEffort'), value: 'reasoning_effort' },
|
|
193
|
+
{ label: t('thinkingParamThinking'), value: 'thinking' },
|
|
194
|
+
],
|
|
195
|
+
description: t('thinkingParamDescription'),
|
|
159
196
|
},
|
|
160
197
|
});
|
|
161
198
|
/**
|
|
@@ -172,28 +209,26 @@ class OpenAIClient {
|
|
|
172
209
|
timeout;
|
|
173
210
|
temperature;
|
|
174
211
|
maxTokens;
|
|
175
|
-
|
|
212
|
+
thinkingFields;
|
|
176
213
|
logger;
|
|
214
|
+
/**
|
|
215
|
+
* 生成请求的连接池:不设等响应头与读响应体的时限(默认各 300 秒),timeout 是唯一的时限。dispatcher 是 Node 自带 fetch
|
|
216
|
+
* 认的扩展字段,DOM 的 RequestInit 里没有(测试的类型检查带 DOM),带它的选项按 RequestInit 断言
|
|
217
|
+
*/
|
|
218
|
+
dispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0 });
|
|
177
219
|
constructor(cfg, logger) {
|
|
178
220
|
this.apiKey = cfg.apiKey;
|
|
179
221
|
this.baseUrl = cfg.baseUrl.replace(/\/+$/, '');
|
|
180
|
-
// schema 中 timeout 单位为「秒」,存储为毫秒;0
|
|
181
|
-
this.timeout =
|
|
222
|
+
// schema 中 timeout 单位为「秒」,存储为毫秒;0 或负数不限,超过定时器上限按上限计,报错里的秒数是生效的值(见 idleTimeout)
|
|
223
|
+
this.timeout = Math.min(cfg.timeout * 1000, IDLE_TIMEOUT_MAX_MS);
|
|
182
224
|
this.temperature = cfg.temperature;
|
|
183
225
|
this.maxTokens = cfg.maxTokens;
|
|
184
|
-
this.
|
|
226
|
+
this.thinkingFields = THINKING_STYLES[cfg.thinkingParam].fields;
|
|
185
227
|
this.logger = logger;
|
|
186
228
|
}
|
|
187
|
-
/**
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
* DeepSeek 风格 `thinking: {type}` 由配置显式开启(真 OpenAI 端点不认此字段会 400);
|
|
191
|
-
* think 未指定时不发字段,端点默认行为不被触碰。
|
|
192
|
-
*/
|
|
193
|
-
applyThinking(body, request) {
|
|
194
|
-
if (!this.thinkingParam || request.think === undefined)
|
|
195
|
-
return;
|
|
196
|
-
body.thinking = { type: request.think ? 'enabled' : 'disabled' };
|
|
229
|
+
/** 停用时关掉生成请求的连接池,之后的请求被拒(报插件已停用或重载)。不等在飞的请求读完:它们是调用方的,等它们会让重载卡到回复写完 */
|
|
230
|
+
close() {
|
|
231
|
+
void this.dispatcher.close();
|
|
197
232
|
}
|
|
198
233
|
/** 构造请求头(无 apiKey 时不发 Authorization) */
|
|
199
234
|
get headers() {
|
|
@@ -202,24 +237,27 @@ class OpenAIClient {
|
|
|
202
237
|
h.Authorization = `Bearer ${this.apiKey}`;
|
|
203
238
|
return h;
|
|
204
239
|
}
|
|
205
|
-
/** 对话请求的非 2xx 应答:截断后的响应体记 warn,抛出的错误见
|
|
240
|
+
/** 对话请求的非 2xx 应答:截断后的响应体记 warn,抛出的错误见 httpErrorMessage */
|
|
206
241
|
apiError(status, body) {
|
|
207
242
|
this.logger.warn(`LLM API 错误 (${status}): ${bodyExcerpt(body)}`);
|
|
208
|
-
return new
|
|
243
|
+
return new TextError(httpErrorMessage('LLM', status, body));
|
|
209
244
|
}
|
|
210
245
|
/**
|
|
211
|
-
* 对话请求没拿到完整应答(fetch 或读响应体时抛出):原始错误连同原因记 warn
|
|
212
|
-
*
|
|
246
|
+
* 对话请求没拿到完整应答(fetch 或读响应体时抛出):原始错误连同原因记 warn,超时、连不上与连接池已关闭(插件停用或重载)
|
|
247
|
+
* 各换成一句说明,其它错误原样返回。调用方经 request.signal 中止时原样返回、不记日志,agent 按中止收尾
|
|
213
248
|
*/
|
|
214
249
|
requestError(err, request, startedAt) {
|
|
215
250
|
if (request.signal?.aborted)
|
|
216
251
|
return err;
|
|
217
|
-
this.logger.warn(`LLM 请求失败 (耗时 ${Date.now() - startedAt}ms): ${describeError(err)}`);
|
|
252
|
+
this.logger.warn(`LLM 请求失败 (耗时 ${Date.now() - startedAt}ms): ${toSource(describeError(err))}`);
|
|
218
253
|
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
|
219
|
-
return new
|
|
254
|
+
return new TextError(t('requestTimeout', { seconds: this.timeout / 1000 }));
|
|
220
255
|
}
|
|
221
256
|
if (err instanceof TypeError && err.message === 'fetch failed') {
|
|
222
|
-
|
|
257
|
+
if (err.cause instanceof errors.ClientClosedError || err.cause instanceof errors.ClientDestroyedError) {
|
|
258
|
+
return new TextError(t('pluginClosed'));
|
|
259
|
+
}
|
|
260
|
+
return new TextError(t('unreachable'));
|
|
223
261
|
}
|
|
224
262
|
return err;
|
|
225
263
|
}
|
|
@@ -230,7 +268,7 @@ class OpenAIClient {
|
|
|
230
268
|
}
|
|
231
269
|
catch {
|
|
232
270
|
this.logger.warn(`LLM 应答不是 JSON (${status}): ${bodyExcerpt(text)}`);
|
|
233
|
-
throw new
|
|
271
|
+
throw new TextError(t('notJson', { status }));
|
|
234
272
|
}
|
|
235
273
|
}
|
|
236
274
|
/**
|
|
@@ -258,20 +296,21 @@ class OpenAIClient {
|
|
|
258
296
|
data = JSON.parse(text);
|
|
259
297
|
}
|
|
260
298
|
catch {
|
|
261
|
-
throw new
|
|
299
|
+
throw new TextError(t('discoveryNotJson', { excerpt: bodyExcerpt(text) }));
|
|
262
300
|
}
|
|
263
301
|
const list = data?.data;
|
|
264
302
|
if (!Array.isArray(list) || !list.every(m => typeof m?.id === 'string')) {
|
|
265
|
-
throw new
|
|
303
|
+
throw new TextError(t('discoveryNotList', { excerpt: bodyExcerpt(JSON.stringify(data)) }));
|
|
266
304
|
}
|
|
267
305
|
return list.map(m => m.id);
|
|
268
306
|
}
|
|
269
307
|
catch (err) {
|
|
270
308
|
signal?.throwIfAborted();
|
|
271
|
-
throw new
|
|
309
|
+
throw new TextError(t('discoveryFailed', { url, error: describeError(err) }), { cause: err });
|
|
272
310
|
}
|
|
273
311
|
}
|
|
274
|
-
|
|
312
|
+
/** level 是请求带的思考档位,没带时不发字段 */
|
|
313
|
+
async chat(model, request, level) {
|
|
275
314
|
const messages = prepareLLMMessages(request.messages).map(m => this.toAPIMessage(m));
|
|
276
315
|
const tools = request.tools?.map(t => this.toAPITool(t));
|
|
277
316
|
const reasoning = isReasoningModel(model);
|
|
@@ -283,14 +322,12 @@ class OpenAIClient {
|
|
|
283
322
|
[reasoning ? 'max_completion_tokens' : 'max_tokens']: request.maxTokens ?? this.maxTokens,
|
|
284
323
|
...(reasoning ? {} : { temperature: request.temperature ?? this.temperature }),
|
|
285
324
|
};
|
|
286
|
-
|
|
325
|
+
if (level !== undefined)
|
|
326
|
+
Object.assign(body, this.thinkingFields(level));
|
|
287
327
|
if (tools && tools.length > 0) {
|
|
288
328
|
body.tools = tools;
|
|
289
329
|
}
|
|
290
330
|
this.logger.debug(`请求 LLM: ${body.model}, ${messages.length} 条消息, ${tools?.length ?? 0} 个工具`);
|
|
291
|
-
const signals = [AbortSignal.timeout(this.timeout)];
|
|
292
|
-
if (request.signal)
|
|
293
|
-
signals.push(request.signal);
|
|
294
331
|
const startedAt = Date.now();
|
|
295
332
|
let response;
|
|
296
333
|
let text;
|
|
@@ -299,7 +336,8 @@ class OpenAIClient {
|
|
|
299
336
|
method: 'POST',
|
|
300
337
|
headers: this.headers,
|
|
301
338
|
body: JSON.stringify(body),
|
|
302
|
-
signal:
|
|
339
|
+
signal: idleTimeout(this.timeout, request.signal).signal,
|
|
340
|
+
dispatcher: this.dispatcher,
|
|
303
341
|
});
|
|
304
342
|
text = await response.text();
|
|
305
343
|
}
|
|
@@ -311,10 +349,11 @@ class OpenAIClient {
|
|
|
311
349
|
const data = this.parseJsonBody(response.status, text);
|
|
312
350
|
const choice = data.choices[0];
|
|
313
351
|
if (!choice) {
|
|
314
|
-
throw new
|
|
352
|
+
throw new TextError(t('emptyChoices'));
|
|
315
353
|
}
|
|
316
354
|
const result = {
|
|
317
355
|
content: choice.message.content,
|
|
356
|
+
finishReason: finishReasonOf(choice.finish_reason),
|
|
318
357
|
};
|
|
319
358
|
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
|
|
320
359
|
result.toolCalls = choice.message.tool_calls.map(tc => ({
|
|
@@ -336,7 +375,7 @@ class OpenAIClient {
|
|
|
336
375
|
}
|
|
337
376
|
return result;
|
|
338
377
|
}
|
|
339
|
-
async *chatStream(model, request) {
|
|
378
|
+
async *chatStream(model, request, level) {
|
|
340
379
|
const messages = prepareLLMMessages(request.messages).map(m => this.toAPIMessage(m));
|
|
341
380
|
const tools = request.tools?.map(t => this.toAPITool(t));
|
|
342
381
|
const reasoning = isReasoningModel(model);
|
|
@@ -350,14 +389,22 @@ class OpenAIClient {
|
|
|
350
389
|
// 流式必须显式声明才会在收尾帧返回 usage(真 OpenAI 端点无此开关则整条流不含 usage)。
|
|
351
390
|
stream_options: { include_usage: true },
|
|
352
391
|
};
|
|
353
|
-
|
|
392
|
+
if (level !== undefined)
|
|
393
|
+
Object.assign(body, this.thinkingFields(level));
|
|
354
394
|
if (tools && tools.length > 0) {
|
|
355
395
|
body.tools = tools;
|
|
356
396
|
}
|
|
357
397
|
this.logger.debug(`流式请求 LLM: ${body.model}, ${messages.length} 条消息`);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
398
|
+
// 交出一段期间不计时:调用方处理一段的时间不算停顿
|
|
399
|
+
const timer = idleTimeout(this.timeout, request.signal);
|
|
400
|
+
for await (const chunk of this.readStream(body, request, timer)) {
|
|
401
|
+
timer.pause();
|
|
402
|
+
yield chunk;
|
|
403
|
+
timer.resume();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
/** 发出流式请求并逐段解析,timer 管这次请求的停顿超时 */
|
|
407
|
+
async *readStream(body, request, timer) {
|
|
361
408
|
const reqStart = Date.now();
|
|
362
409
|
const slowConnectTimer = setTimeout(() => {
|
|
363
410
|
this.logger.warn(`LLM 连接慢:已等待 15s 仍未收到响应头 (url=${this.baseUrl}/chat/completions)`);
|
|
@@ -368,7 +415,8 @@ class OpenAIClient {
|
|
|
368
415
|
method: 'POST',
|
|
369
416
|
headers: this.headers,
|
|
370
417
|
body: JSON.stringify(body),
|
|
371
|
-
signal:
|
|
418
|
+
signal: timer.signal,
|
|
419
|
+
dispatcher: this.dispatcher,
|
|
372
420
|
});
|
|
373
421
|
}
|
|
374
422
|
catch (err) {
|
|
@@ -384,8 +432,9 @@ class OpenAIClient {
|
|
|
384
432
|
throw this.apiError(response.status, errorText);
|
|
385
433
|
}
|
|
386
434
|
const toolCallBuffers = new Map();
|
|
435
|
+
let finishReason;
|
|
387
436
|
if (!response.body) {
|
|
388
|
-
throw new
|
|
437
|
+
throw new TextError(t('emptyBody'));
|
|
389
438
|
}
|
|
390
439
|
const reader = response.body.getReader();
|
|
391
440
|
const decoder = new TextDecoder();
|
|
@@ -421,14 +470,17 @@ class OpenAIClient {
|
|
|
421
470
|
for (const [, tc] of [...toolCallBuffers.entries()].sort((a, b) => a[0] - b[0])) {
|
|
422
471
|
toolCalls.push({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.args } });
|
|
423
472
|
}
|
|
424
|
-
yield { done: true, toolCalls: toolCalls.length > 0 ? toolCalls : undefined };
|
|
473
|
+
yield { done: true, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, finishReason };
|
|
425
474
|
return;
|
|
426
475
|
}
|
|
427
476
|
try {
|
|
428
477
|
const data = JSON.parse(payload);
|
|
429
478
|
const chunk = {};
|
|
430
|
-
// usage
|
|
431
|
-
//
|
|
479
|
+
// usage 与结束原因必须在 delta 守卫**之前**取:OpenAI 的 include_usage 形态把 usage 挂在 `choices: []` 的
|
|
480
|
+
// 收尾帧上,兼容端点的结束原因可能在不带 delta 的末帧上,等到守卫之后再取整帧已被跳过。
|
|
481
|
+
const reason = finishReasonOf(data.choices?.[0]?.finish_reason);
|
|
482
|
+
if (reason)
|
|
483
|
+
finishReason = reason;
|
|
432
484
|
if (data.usage) {
|
|
433
485
|
chunk.usage = {
|
|
434
486
|
promptTokens: data.usage.prompt_tokens,
|
|
@@ -443,11 +495,17 @@ class OpenAIClient {
|
|
|
443
495
|
yield chunk; // 纯 usage 收尾帧照样上报
|
|
444
496
|
continue;
|
|
445
497
|
}
|
|
498
|
+
// 带生成内容的增量重新计时:思考本插件不交出,照样算(兼容端点写在 reasoning_content 或 reasoning)
|
|
499
|
+
if (delta.content || delta.reasoning_content || delta.reasoning)
|
|
500
|
+
timer.touch();
|
|
446
501
|
if (delta.content)
|
|
447
502
|
chunk.contentDelta = delta.content;
|
|
448
|
-
//
|
|
503
|
+
// 累积工具调用;同一帧里有几个调用,进度就各报一次
|
|
504
|
+
const progress = [];
|
|
449
505
|
if (delta.tool_calls) {
|
|
450
506
|
for (const tc of delta.tool_calls) {
|
|
507
|
+
if (tc.function?.name || tc.function?.arguments)
|
|
508
|
+
timer.touch();
|
|
451
509
|
const idx = tc.index ?? 0;
|
|
452
510
|
if (tc.id) {
|
|
453
511
|
toolCallBuffers.set(idx, { id: tc.id, name: tc.function?.name ?? '', args: '' });
|
|
@@ -458,18 +516,14 @@ class OpenAIClient {
|
|
|
458
516
|
entry.name = tc.function.name;
|
|
459
517
|
if (tc.function?.arguments)
|
|
460
518
|
entry.args += tc.function.arguments;
|
|
461
|
-
|
|
462
|
-
chunk.toolCallProgress = {
|
|
463
|
-
index: idx,
|
|
464
|
-
name: entry.name,
|
|
465
|
-
charsAccumulated: entry.args.length,
|
|
466
|
-
};
|
|
519
|
+
progress.push({ index: idx, name: entry.name, charsAccumulated: entry.args.length });
|
|
467
520
|
}
|
|
468
521
|
}
|
|
469
522
|
}
|
|
470
|
-
if (chunk.contentDelta || chunk.usage
|
|
523
|
+
if (chunk.contentDelta || chunk.usage)
|
|
471
524
|
yield chunk;
|
|
472
|
-
|
|
525
|
+
for (const toolCallProgress of progress)
|
|
526
|
+
yield { toolCallProgress };
|
|
473
527
|
}
|
|
474
528
|
catch {
|
|
475
529
|
/* skip malformed JSON */
|
|
@@ -485,7 +539,7 @@ class OpenAIClient {
|
|
|
485
539
|
clearTimeout(streamStallTimer);
|
|
486
540
|
// cancel 而非仅 releaseLock:中止/提前退出时要主动关闭响应体。releaseLock 只是放锁,
|
|
487
541
|
// 底层流仍开着——被打断的生成会让 undici 挂着半读的响应与套接字,直到请求超时兜底
|
|
488
|
-
//
|
|
542
|
+
// 才释放(本地慢生成时回合常被停下,这类残留一挂就是整个超时窗)。
|
|
489
543
|
// cancel 自带放锁。三条退出路径的语义:读尽 EOF(done:true)→ 流已 closed,cancel 是
|
|
490
544
|
// 真 no-op;收到终帧即 return(未读到 EOF)与消费方提前 break → cancel 中止残余响应体,
|
|
491
545
|
// 这正是本修复要的「立即交还套接字」,代价是该连接不回 keep-alive 池。
|
|
@@ -496,7 +550,7 @@ class OpenAIClient {
|
|
|
496
550
|
for (const [, tc] of [...toolCallBuffers.entries()].sort((a, b) => a[0] - b[0])) {
|
|
497
551
|
toolCalls.push({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.args } });
|
|
498
552
|
}
|
|
499
|
-
yield { done: true, toolCalls: toolCalls.length > 0 ? toolCalls : undefined };
|
|
553
|
+
yield { done: true, toolCalls: toolCalls.length > 0 ? toolCalls : undefined, finishReason };
|
|
500
554
|
}
|
|
501
555
|
toAPIMessage(msg) {
|
|
502
556
|
// 调用方已经 prepareLLMMessages 处理过:role 已是 WellKnownRole,
|
|
@@ -551,8 +605,7 @@ class OpenAIClient {
|
|
|
551
605
|
const { Chat, ToolCalling, Streaming, Vision, Thinking } = LLMCapabilities;
|
|
552
606
|
// resolveCapabilities 的前缀匹配按表序取第一个命中,更具体的前缀必须排在更短的前缀之前
|
|
553
607
|
// (gpt-4.1 在 gpt-4 之前,否则 gpt-4.1-mini 会命中 gpt-4、丢掉 Vision)。
|
|
554
|
-
// 表里的 Vision 影响 media
|
|
555
|
-
// WebUI(/api/status)是否显示图片上传按钮;漏标只是退回识别模型转文字,误标会把原图直通给
|
|
608
|
+
// 表里的 Vision 影响 media 的视觉路由与 vision.delivery=auto 的交付判定;漏标只是退回识别模型转文字,误标会把原图直通给
|
|
556
609
|
// 不收图的模型,故只收确认全系支持看图的族。
|
|
557
610
|
const MODEL_CAPABILITIES = {
|
|
558
611
|
'gpt-4o': [Chat, ToolCalling, Streaming, Vision],
|
|
@@ -560,6 +613,8 @@ const MODEL_CAPABILITIES = {
|
|
|
560
613
|
'gpt-4.1': [Chat, ToolCalling, Streaming, Vision],
|
|
561
614
|
'gpt-4-turbo': [Chat, ToolCalling, Streaming],
|
|
562
615
|
'gpt-4': [Chat, ToolCalling, Streaming],
|
|
616
|
+
// gpt-5-chat 是非推理的对话模型,不收思考档位
|
|
617
|
+
'gpt-5-chat': [Chat, ToolCalling, Streaming, Vision],
|
|
563
618
|
'gpt-5': [Chat, ToolCalling, Streaming, Vision, Thinking],
|
|
564
619
|
'gpt-3.5-turbo': [Chat, ToolCalling, Streaming],
|
|
565
620
|
o1: [Chat, Thinking],
|
|
@@ -665,10 +720,13 @@ class OpenAIModelHandle {
|
|
|
665
720
|
contextLength;
|
|
666
721
|
maxOutputTokens;
|
|
667
722
|
capabilities;
|
|
723
|
+
thinking;
|
|
668
724
|
refresh;
|
|
669
725
|
constructor(client, id, providerId, contextLength, maxOutputTokens,
|
|
670
726
|
/** 该 model 的能力元数据(供 media 发现/下拉展示读取,非 DI 选择机制)。 */
|
|
671
727
|
capabilities,
|
|
728
|
+
/** 能调思考的模型才有:按实例选的写法与模型族 */
|
|
729
|
+
thinking,
|
|
672
730
|
/** Provider 级共享的 refresh 闭包;webui 按 providerId 找到该 provider 的任一 entry 调一次即可。关闭模型发现时没有 */
|
|
673
731
|
refresh) {
|
|
674
732
|
this.client = client;
|
|
@@ -677,17 +735,18 @@ class OpenAIModelHandle {
|
|
|
677
735
|
this.contextLength = contextLength;
|
|
678
736
|
this.maxOutputTokens = maxOutputTokens;
|
|
679
737
|
this.capabilities = capabilities;
|
|
738
|
+
this.thinking = thinking;
|
|
680
739
|
this.refresh = refresh;
|
|
681
740
|
}
|
|
682
|
-
chat(request) {
|
|
683
|
-
return this.client.chat(this.id, request);
|
|
741
|
+
async chat(request) {
|
|
742
|
+
return this.client.chat(this.id, request, requestedThinking(this, request));
|
|
684
743
|
}
|
|
685
|
-
chatStream(request) {
|
|
686
|
-
|
|
744
|
+
async *chatStream(request) {
|
|
745
|
+
yield* this.client.chatStream(this.id, request, requestedThinking(this, request));
|
|
687
746
|
}
|
|
688
747
|
}
|
|
689
748
|
// ===== 插件定义 =====
|
|
690
|
-
const uses = { config, logger, lifecycle, provide };
|
|
749
|
+
const uses = { config, logger, lifecycle, provide, events };
|
|
691
750
|
export default definePlugin({
|
|
692
751
|
name: '@aalis/plugin-llm-openai',
|
|
693
752
|
displayName: 'OpenAI',
|
|
@@ -698,23 +757,24 @@ export default definePlugin({
|
|
|
698
757
|
uses,
|
|
699
758
|
apply: registerModels,
|
|
700
759
|
});
|
|
701
|
-
async function registerModels({ config, logger, lifecycle, provide }) {
|
|
702
|
-
const cfg = parseConfig(configSchema, config, logger);
|
|
760
|
+
async function registerModels({ config, logger, lifecycle, provide, events }) {
|
|
761
|
+
const cfg = parseConfig(configSchema, config.use, logger.use);
|
|
703
762
|
const customModels = parseCustomModels(cfg.customModels);
|
|
704
763
|
const modelCapabilities = parseModelCapabilities(cfg.modelCapabilities);
|
|
705
764
|
const providerCapabilities = parseProviderCapabilities(cfg.providerCapabilities);
|
|
706
765
|
checkBaseUrl(cfg.baseUrl);
|
|
707
|
-
//
|
|
766
|
+
// 官方端点不收不带 key 的请求:留空时激活就报缺 apiKey,不等第一次调用才回 401;兼容端点(本地服务等)可以不要 key,
|
|
767
|
+
// 所以只按 host 认官方(按前缀匹配会误伤 api.openai.com.cn 这类镜像域名)
|
|
708
768
|
const isOfficialOpenAI = new URL(cfg.baseUrl).hostname === 'api.openai.com';
|
|
709
769
|
if (!cfg.apiKey && isOfficialOpenAI) {
|
|
710
|
-
throw missingConfigError('apiKey', '
|
|
770
|
+
throw missingConfigError('apiKey', t('apiKeyRequired'));
|
|
711
771
|
}
|
|
712
772
|
if (!cfg.discoverModels && customModels.length === 0) {
|
|
713
|
-
throw missingConfigError('customModels', '
|
|
773
|
+
throw missingConfigError('customModels', t('customModelsRequired'));
|
|
714
774
|
}
|
|
715
|
-
const client = new OpenAIClient(cfg, logger);
|
|
775
|
+
const client = new OpenAIClient(cfg, logger.use);
|
|
776
|
+
lifecycle.use.onDispose(() => client.close(), '关闭生成请求的连接池');
|
|
716
777
|
const baseLabel = `OpenAI (${cfg.baseUrl.replace(/^https?:\/\//, '')})`;
|
|
717
|
-
const shownBaseUrl = redactUrl(cfg.baseUrl);
|
|
718
778
|
// 已注册 model entry 的句柄表:modelId → dispose(来自 provide 返回值)
|
|
719
779
|
const registered = new Map();
|
|
720
780
|
// 关闭模型发现时不提供 refresh:没有可重新发现的列表
|
|
@@ -723,10 +783,10 @@ async function registerModels({ config, logger, lifecycle, provide }) {
|
|
|
723
783
|
if (registered.has(modelId))
|
|
724
784
|
return;
|
|
725
785
|
const capabilities = resolveCapabilities(modelId, modelCapabilities.get(modelId), providerCapabilities);
|
|
726
|
-
const handle = new OpenAIModelHandle(client, modelId, lifecycle.id, cfg.contextLength, cfg.maxTokens, capabilities, refresh);
|
|
727
|
-
const dispose = provide(llm, handle, {
|
|
786
|
+
const handle = new OpenAIModelHandle(client, modelId, lifecycle.use.id, cfg.contextLength, cfg.maxTokens, capabilities, capabilities.includes(Thinking) ? THINKING_STYLES[cfg.thinkingParam].levels(modelId) : undefined, refresh);
|
|
787
|
+
const dispose = provide.use(llm, metered(handle, events.use), {
|
|
728
788
|
label: `${baseLabel} / ${modelId}`,
|
|
729
|
-
entryId: `${lifecycle.id}/${modelId}`,
|
|
789
|
+
entryId: `${lifecycle.use.id}/${modelId}`,
|
|
730
790
|
});
|
|
731
791
|
registered.set(modelId, dispose);
|
|
732
792
|
}
|
|
@@ -738,7 +798,7 @@ async function registerModels({ config, logger, lifecycle, provide }) {
|
|
|
738
798
|
d();
|
|
739
799
|
}
|
|
740
800
|
catch (err) {
|
|
741
|
-
logger.warn(`卸载 model entry "${modelId}" 失败: ${err}`);
|
|
801
|
+
logger.use.warn(`卸载 model entry "${modelId}" 失败: ${err}`);
|
|
742
802
|
}
|
|
743
803
|
registered.delete(modelId);
|
|
744
804
|
}
|
|
@@ -747,7 +807,7 @@ async function registerModels({ config, logger, lifecycle, provide }) {
|
|
|
747
807
|
const remoteSet = new Set(remoteIds);
|
|
748
808
|
for (const cm of customModels) {
|
|
749
809
|
if (remoteSet.has(cm)) {
|
|
750
|
-
logger.warn(`自定义模型 "${cm}" 与自动发现的模型重复,请在配置中去重`);
|
|
810
|
+
logger.use.warn(`自定义模型 "${cm}" 与自动发现的模型重复,请在配置中去重`);
|
|
751
811
|
}
|
|
752
812
|
}
|
|
753
813
|
return [...remoteIds, ...customModels.filter(id => !remoteSet.has(id))];
|
|
@@ -757,36 +817,36 @@ async function registerModels({ config, logger, lifecycle, provide }) {
|
|
|
757
817
|
// err 交给 logger 会按因果链把原因再记一遍
|
|
758
818
|
let discoveryError;
|
|
759
819
|
const remoteIds = cfg.discoverModels
|
|
760
|
-
? await client.fetchRemoteModelIds(lifecycle.signal).catch((err) => {
|
|
761
|
-
discoveryError =
|
|
820
|
+
? await client.fetchRemoteModelIds(lifecycle.use.signal).catch((err) => {
|
|
821
|
+
discoveryError = textOf(err);
|
|
762
822
|
return [];
|
|
763
823
|
})
|
|
764
824
|
: [];
|
|
765
|
-
lifecycle.signal.throwIfAborted();
|
|
825
|
+
lifecycle.use.signal.throwIfAborted();
|
|
766
826
|
const initialIds = withCustomModels(remoteIds);
|
|
767
827
|
// 一个模型都没有时抛配置错误,原因成为实例的错误信息;否则只剩 core 的「声明 provides [llm] 但未实际注册」。
|
|
768
828
|
// 提示写在原因之前:原因里可能带着一段响应体
|
|
769
829
|
if (initialIds.length === 0) {
|
|
770
830
|
throw configError(discoveryError
|
|
771
|
-
?
|
|
772
|
-
:
|
|
831
|
+
? t('noModelsDiscoveryFailed', { error: discoveryError })
|
|
832
|
+
: t('noModels', { baseUrl: cfg.baseUrl }));
|
|
773
833
|
}
|
|
774
834
|
if (discoveryError)
|
|
775
|
-
logger.warn(`启动时只注册 customModels 里的模型;${discoveryError}`);
|
|
835
|
+
logger.use.warn(`启动时只注册 customModels 里的模型;${toSource(discoveryError)}`);
|
|
776
836
|
for (const modelId of initialIds)
|
|
777
837
|
registerOne(modelId);
|
|
778
|
-
logger.info(!cfg.discoverModels
|
|
779
|
-
? `未开启模型发现: ${
|
|
838
|
+
logger.use.info(!cfg.discoverModels
|
|
839
|
+
? `未开启模型发现: ${cfg.baseUrl},注册 customModels 里的 ${initialIds.length} 个 model entry`
|
|
780
840
|
: discoveryError
|
|
781
|
-
? `模型发现失败: ${
|
|
782
|
-
: `已连接: ${
|
|
841
|
+
? `模型发现失败: ${cfg.baseUrl},注册 customModels 里的 ${initialIds.length} 个 model entry`
|
|
842
|
+
: `已连接: ${cfg.baseUrl},注册 ${initialIds.length} 个 model entry`);
|
|
783
843
|
/**
|
|
784
844
|
* 重新发现并按差异增删条目(WebUI 触发)。与初次注册一样随停用或停机中止:中止即抛出,不再增删条目。
|
|
785
845
|
* 发现失败同样抛出(WebUI 据此报错)、不增删条目:按空列表处理会把自动发现的条目全部注销
|
|
786
846
|
*/
|
|
787
847
|
async function refreshModels() {
|
|
788
|
-
const next = withCustomModels(await client.fetchRemoteModelIds(lifecycle.signal));
|
|
789
|
-
lifecycle.signal.throwIfAborted();
|
|
848
|
+
const next = withCustomModels(await client.fetchRemoteModelIds(lifecycle.use.signal));
|
|
849
|
+
lifecycle.use.signal.throwIfAborted();
|
|
790
850
|
const nextSet = new Set(next);
|
|
791
851
|
const added = [];
|
|
792
852
|
const removed = [];
|
|
@@ -803,10 +863,10 @@ async function registerModels({ config, logger, lifecycle, provide }) {
|
|
|
803
863
|
}
|
|
804
864
|
}
|
|
805
865
|
if (added.length || removed.length) {
|
|
806
|
-
logger.info(`OpenAI 模型列表已刷新: +${added.length} (${added.join(',') || '-'}) / -${removed.length} (${removed.join(',') || '-'}) / 现共 ${registered.size}`);
|
|
866
|
+
logger.use.info(`OpenAI 模型列表已刷新: +${added.length} (${added.join(',') || '-'}) / -${removed.length} (${removed.join(',') || '-'}) / 现共 ${registered.size}`);
|
|
807
867
|
}
|
|
808
868
|
else {
|
|
809
|
-
logger.debug(`OpenAI 模型列表已刷新: 无变化 (共 ${registered.size})`);
|
|
869
|
+
logger.use.debug(`OpenAI 模型列表已刷新: 无变化 (共 ${registered.size})`);
|
|
810
870
|
}
|
|
811
871
|
return { added, removed, total: registered.size };
|
|
812
872
|
}
|