llm.openai.js 8.91 KB
/**
 * 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)
}