/** * 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} */ 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} */ 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} */ 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, headers:Record, 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} */ 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, 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} */ _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} */ 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) }