llm.openai.js
8.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
/**
* OpenAI Chat Completions 子类 —— 只覆写 chat + messageToValue。
*
* 流式解析 / 重试 / trace / 多模态归一全部在 LLMBase,本文件不复制其中任何一项。
* 厂商专有概念(Bearer header、`choices[].delta`、`stream_options`、usage 字段名)只留在此。
*/
import { LLMBase } from '../base.js'
const DEFAULT_BASE_URL = 'https://api.openai.com/v1'
export default class OpenAIProvider extends LLMBase {
/**
* 缺省 baseUrl。OpenAI 兼容厂商(如 Moonshot)只需覆写本方法即可换 host,
* 无需复制 chat 逻辑。lease.baseUrl 永远优先。
* @protected
* @returns {string}
*/
defaultBaseUrl() {
return DEFAULT_BASE_URL
}
/**
* @param {import('../index.js').ChatRequest} req
* @param {import('../index.js').ChatOpts} [opts]
* @returns {Promise<import('../base.js').StreamResult>}
*/
async chat(req, opts = {}) {
const model = req.model ?? this.model
const result = await this.request({
req,
opts,
affinity_key: opts.trace_ctx?.session_id,
doFetch: (lease, { signal }) => {
const base = lease.base_url ?? this.defaultBaseUrl()
const headers = this.chatCompletionsHeaders(lease)
/** @type {Record<string, any>} */
const body = {
model,
messages: this._toOpenAIMessages(req),
stream: true,
stream_options: { include_usage: true },
}
if (req.temperature != null) body.temperature = req.temperature
if (req.max_tokens != null) body[this.maxTokensParamName(model)] = req.max_tokens
if (req.tools) body.tools = this._toOpenAITools(req.tools)
if (req.thinking && this.supportsThinkingParam(model)) body.thinking = req.thinking
this.applyResponseFormat(body, headers, req.response_format)
return this.httpFetch(`${base}/chat/completions`, {
method: 'POST',
signal,
headers,
body: JSON.stringify(body),
})
},
})
return result
}
/**
* @protected
* @param {import('../index.js').KeyLease} lease
* @returns {Record<string, string>}
*/
chatCompletionsHeaders(lease) {
return {
'content-type': 'application/json',
authorization: `Bearer ${lease.api_key}`,
}
}
/**
* OpenAI Chat Completions structured output 分派表:
* json_object → response_format.type='json_object'(原样透传,无 schema)。
* json_schema → response_format.json_schema(name+schema+strict)。
* @protected
* @returns {Record<string, (body:Record<string,any>, headers:Record<string,string>, rf:{schema?:object, name?:string}) => void>}
*/
responseFormatHandlers() {
return {
json_object: (body) => {
body.response_format = { type: 'json_object' }
},
json_schema: (body, _headers, rf) => {
body.response_format = {
type: 'json_schema',
json_schema: { name: rf.name ?? 'response', schema: rf.schema, strict: true },
}
},
}
}
/**
* @protected
* @param {string|undefined} _model
* @returns {string}
*/
maxTokensParamName(_model) {
return 'max_tokens'
}
/**
* @protected
* @param {string|undefined} _model
* @returns {boolean}
*/
supportsThinkingParam(_model) {
return true
}
/**
* 把统一 ChatRequest.messages + 多模态归一成 OpenAI messages。
* @protected
* @param {import('../index.js').ChatRequest} req
* @returns {Array<{ role:string, content:any }>}
*/
_toOpenAIMessages(req) {
/** @type {Array<{ role:string, content:any }>} */
const messages = req.messages.map((m) => this._toOpenAIMessage(m))
const images = this.buildMediaParts(req.images, 'image')
if (images.length && messages.length) {
const last = messages[messages.length - 1]
/** @type {Array<any>} */
const parts = [{ type: 'text', text: typeof last.content === 'string' ? last.content : '' }]
for (const img of images) {
const url = img.url ?? `data:${img.mime};base64,${img.data}`
parts.push({ type: 'image_url', image_url: { url } })
}
last.content = parts
}
return messages
}
/**
* 单条中性消息 → OpenAI 消息形态。tool 相关的两分支(中性载荷承载于 content)在此落地:
* assistant tool_calls → tool_calls 字段;tool 角色 → role:'tool' + tool_call_id。
* @protected
* @param {import('../index.js').ChatMessage} m
* @returns {{ role:string, content:any, tool_calls?:Array<object>, tool_call_id?:string }}
*/
_toOpenAIMessage(m) {
if (m.role === 'assistant' && m.content?.kind === 'tool_calls') {
return {
role: 'assistant',
content: null,
tool_calls: m.content.tool_calls.map((c) => ({
id: c.id,
type: 'function',
function: { name: c.name, arguments: JSON.stringify(c.args) },
})),
}
}
if (m.role === 'tool') {
return { role: 'tool', tool_call_id: m.content.tool_call_id, content: m.content.result }
}
return { role: m.role, content: m.content }
}
/**
* 中性 tool spec → OpenAI function tool 形态。
* @protected
* @param {Array<{ name:string, description:string, parameters:object }>} tools
* @returns {Array<object>}
*/
_toOpenAITools(tools) {
return tools.map((t) => ({
type: 'function',
function: { name: t.name, description: t.description, parameters: t.parameters },
}))
}
/**
* @param {any} frame OpenAI SSE 帧(已解析 JSON)。
* @returns {import('../base.js').ParsedValue | null}
*/
messageToValue(frame) {
if (!frame || typeof frame !== 'object') return null
if (frame.usage) {
const usage = frame.usage
const prompt_tokens = readUsageInteger(usage.prompt_tokens, 'prompt_tokens', this.provider)
const completion_tokens = readUsageInteger(usage.completion_tokens, 'completion_tokens', this.provider)
const total_tokens = readUsageInteger(usage.total_tokens, 'total_tokens', this.provider)
if (total_tokens !== prompt_tokens + completion_tokens) {
throw new Error(
`[${this.provider}] usage.total_tokens=${total_tokens} 与 prompt_tokens + completion_tokens 不一致`,
)
}
return [
{
type: 'metadata',
provider_request_id: typeof frame.id === 'string' ? frame.id : null,
service_tier: typeof frame.service_tier === 'string' ? frame.service_tier : null,
},
{
type: 'usage',
usage: {
prompt_tokens,
completion_tokens,
total_tokens,
cached_tokens: readOptionalUsageInteger(usage.prompt_tokens_details?.cached_tokens, 'cached_tokens', this.provider),
reasoning_tokens: readOptionalUsageInteger(
usage.completion_tokens_details?.reasoning_tokens,
'reasoning_tokens',
this.provider,
),
},
},
]
}
const choice = frame.choices?.[0]
const delta = choice?.delta
/** @type {Array<import('../base.js').ParsedValue>} */
const values = []
if (!delta && choice?.finish_reason === undefined) return null
if (delta?.tool_calls?.length) {
// 真实流:function.arguments 跨多帧分片。逐路发 tool_call_delta(首帧携 id/name,续帧仅 arguments 片段),
// 由 base 按 index 聚合、流结束时一次 JSON.parse。provider 保持无状态,不在此拼接或解析。
values.push(...delta.tool_calls.map((t) => ({
type: 'tool_call_delta',
index: t.index ?? 0,
id: t.id,
name: t.function?.name,
args_fragment: t.function?.arguments ?? '',
})))
}
// content 优先:部分 OpenAI 兼容网关(如万界 maas)每帧都带空串 `reasoning_content`,
// 若先判 reasoning 会把空串当思维链返回、吞掉真正的 content。故仅当 reasoning_content
// 非空时才作为思维链,content 为非空字符串时优先作为正文。
if (typeof delta?.content === 'string' && delta.content.length > 0) {
values.push({ type: 'text', text: delta.content })
} else if (typeof delta?.reasoning_content === 'string' && delta.reasoning_content.length > 0) {
values.push({ type: 'reasoning', text: delta.reasoning_content })
} else if (typeof delta?.content === 'string') {
values.push({ type: 'text', text: delta.content })
}
if (choice?.finish_reason !== undefined) {
values.push({ type: 'finish', finish_reason: choice.finish_reason })
}
return values.length ? values : null
}
}
export function readUsageInteger(value, field, provider) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`[${provider}] usage.${field} 必须是非负安全整数`)
}
return value
}
export function readOptionalUsageInteger(value, field, provider) {
if (value === undefined) return 0
return readUsageInteger(value, field, provider)
}