@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.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,解析不了的与带用户名或密码的抛配置错误。带凭据的 URL fetch 拒发,凭据还会随报错与
23
- * 条目名称(WebUI 的模型下拉)显示出来;解析不了的无从判断有没有凭据,请求同样发不出去。消息不带 URL
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('baseUrl 不是有效的 URL,需写成完整地址,如 https://api.openai.com/v1');
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('baseUrl 不能带用户名或密码(user:pass@),密钥请填在 apiKey');
31
+ throw configError(t('baseUrlCredentials'));
32
32
  }
33
33
  }
34
34
  /**
35
- * 错误信息与日志里显示的 URL:去掉查询串(有的网关把密钥写在查询串里)。没有查询串时原样返回,保留配置里的写法。
36
- * 带用户名或密码的 baseUrl 读配置时已拒绝(见 checkBaseUrl)
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 `${err instanceof Error ? err.message : String(err)}${detail ? ` ← ${detail}` : ''}`;
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 `${provider} API 错误 (${status})${hint ? `:${hint}` : ''};${message ? `上游说明:${message}` : '详情见日志'}`;
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
- /** 解析 API 错误:内容审查类错误按完整响应体识别,给固定提示;其余见 httpErrorMessage */
95
- function parseApiError(provider, status, body) {
96
- const lower = body.toLowerCase();
97
- if (status === 400 && CONTENT_FILTER_PATTERNS.some(p => lower.includes(p))) {
98
- return `${provider} 拒绝了此次请求(内容安全策略),请尝试换一个话题或缩短上下文`;
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 httpErrorMessage(provider, status, body);
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: 'OpenAI API 密钥(本地服务可留空)',
132
+ description: t('apiKeyDescription'),
109
133
  },
110
134
  baseUrl: {
111
135
  type: 'string',
112
- label: 'API 地址',
136
+ label: t('baseUrlLabel'),
113
137
  default: 'https://api.openai.com/v1',
114
138
  onInvalid: 'error',
115
- description: 'API 端点完整前缀(含版本段,如 https://api.openai.com/v1);插件只在其后拼 /chat/completions 与 /models。' +
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: '启动时请求 /models 发现可用模型,WebUI 可刷新模型列表。网关不提供 /models 时关闭:不发发现请求,只注册 customModels(此时必填),也不支持刷新。',
151
+ description: t('discoverModelsDescription'),
129
152
  },
130
153
  modelCapabilities: {
131
154
  type: 'textarea',
132
- label: '单模型能力覆盖',
155
+ label: t('modelCapabilitiesLabel'),
133
156
  default: '',
134
- description: '按行指定某个模型的能力集。有该模型的表项时**覆盖**插件启发式推断,与 adapter 默认能力仍取并集。\n格式:`<modelId>: <cap1>,<cap2>,...`,每行一条。如:gpt-4o: chat,tool_calling,vision,streaming\n可用能力:chat / tool_calling / vision / streaming / thinking 等。',
157
+ description: t('modelCapabilitiesDescription'),
135
158
  },
136
159
  providerCapabilities: {
137
160
  type: 'string',
138
- label: '适配器默认能力(逗号分隔)',
161
+ label: t('providerCapabilitiesLabel'),
139
162
  default: '',
140
- description: '为本适配器下所有模型额外补充的能力。最终某模型的能力 = 此处能力 ∪ 模型级别能力。例:chat,tool_calling,streaming',
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: 'LLM 请求超时时间(秒)。思考模式或长文本建议适当调大。0 = 不限制。',
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: 'boolean',
153
- label: '透传 thinking 开关(DeepSeek 风格)',
154
- default: false,
155
- description: '开启后把请求的 think 开关编码为 DeepSeek 风格的 `thinking: {type: enabled|disabled}` 字段发给端点,' +
156
- '使会话级 /session.set -t 与平台档 think 对本 provider 生效。' +
157
- '仅在端点是 DeepSeek 或会原样透传该字段的中转时开启——OpenAI 官方端点不认此字段会拒收请求。' +
158
- '请求未指定 think 时不发送该字段(沿用端点默认)。',
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
- thinkingParam;
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 = cfg.timeout > 0 ? cfg.timeout * 1000 : 2_147_483_647;
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.thinkingParam = cfg.thinkingParam;
226
+ this.thinkingFields = THINKING_STYLES[cfg.thinkingParam].fields;
185
227
  this.logger = logger;
186
228
  }
187
- /**
188
- * 请求的 think 开关 → 线上字段。本插件原本只按真 OpenAI 写(无思考开关,o 系列隐式推理),
189
- * request.think 一直被无视——走 OpenAI 兼容中转的 DeepSeek 因此收不到会话级开关。
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,抛出的错误见 parseApiError */
240
+ /** 对话请求的非 2xx 应答:截断后的响应体记 warn,抛出的错误见 httpErrorMessage */
206
241
  apiError(status, body) {
207
242
  this.logger.warn(`LLM API 错误 (${status}): ${bodyExcerpt(body)}`);
208
- return new Error(parseApiError('LLM', status, body));
243
+ return new TextError(httpErrorMessage('LLM', status, body));
209
244
  }
210
245
  /**
211
- * 对话请求没拿到完整应答(fetch 或读响应体时抛出):原始错误连同原因记 warn,超时与连不上各换成一句说明,其它错误
212
- * 原样返回。调用方经 request.signal 中止时原样返回、不记日志,agent 按中止收尾
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 Error(`LLM 请求超时:${this.timeout / 1000} 秒内没有完成,可在配置里调大 timeout`);
254
+ return new TextError(t('requestTimeout', { seconds: this.timeout / 1000 }));
220
255
  }
221
256
  if (err instanceof TypeError && err.message === 'fetch failed') {
222
- return new Error('LLM 连不上服务:检查 baseUrl 与网络,详情见日志');
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 Error(`LLM 应答不是 JSON (${status});详情见日志`);
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 Error(`响应不是 JSON: ${bodyExcerpt(text)}`);
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 Error(`响应不是模型列表(需要 data 数组,每项带字符串 id): ${bodyExcerpt(JSON.stringify(data))}`);
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 Error(`模型发现失败 ${redactUrl(url)}: ${describeError(err)}`, { cause: err });
309
+ throw new TextError(t('discoveryFailed', { url, error: describeError(err) }), { cause: err });
272
310
  }
273
311
  }
274
- async chat(model, request) {
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
- this.applyThinking(body, request);
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: AbortSignal.any(signals),
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 Error('LLM 返回了空的 choices');
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
- this.applyThinking(body, request);
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
- const signals = [AbortSignal.timeout(this.timeout)];
359
- if (request.signal)
360
- signals.push(request.signal);
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: AbortSignal.any(signals),
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 Error('LLM API 返回了空的响应体,无法进行流式读取');
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 必须在 delta 守卫**之前**取:OpenAI 的 include_usage 形态把
431
- // usage 挂在 `choices: []` 的收尾帧上,等到守卫之后再取整帧已被跳过。
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
- // 每次 delta 都 yield 进度(不影响最终 done chunk)
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 || chunk.toolCallProgress) {
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
- // 才释放(本地慢生成 + lane 中止高频后,这类残留一挂就是整个超时窗)。
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 的视觉路由、vision.delivery=auto 的交付判定,以及 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
- return this.client.chatStream(this.id, request);
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
- // 官方端点判定按 URL host(前缀字符串匹配会误伤 api.openai.com.cn 等镜像域名)
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', '使用 OpenAI 官方 API 时必填');
770
+ throw missingConfigError('apiKey', t('apiKeyRequired'));
711
771
  }
712
772
  if (!cfg.discoverModels && customModels.length === 0) {
713
- throw missingConfigError('customModels', '关闭 discoverModels 时必填');
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 = err instanceof Error ? err.message : String(err);
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
- ? `未配置 customModels,没有可注册的模型;${discoveryError}`
772
- : `已连接 ${shownBaseUrl},但未发现任何可用模型;可在 customModels 里写明要用的模型`);
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
- ? `未开启模型发现: ${shownBaseUrl},注册 customModels 里的 ${initialIds.length} 个 model entry`
838
+ logger.use.info(!cfg.discoverModels
839
+ ? `未开启模型发现: ${cfg.baseUrl},注册 customModels 里的 ${initialIds.length} 个 model entry`
780
840
  : discoveryError
781
- ? `模型发现失败: ${shownBaseUrl},注册 customModels 里的 ${initialIds.length} 个 model entry`
782
- : `已连接: ${shownBaseUrl},注册 ${initialIds.length} 个 model entry`);
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
  }