llm.volcengine.js 3.11 KB
/**
 * Volcengine(火山方舟 / 豆包)子类 —— OpenAI 兼容,差异在默认 baseUrl、video_url 多模态、
 * usage 的 cached_tokens。继承 OpenAIProvider,零复制 chat/流解析/重试/trace。
 *
 * usage 与思维链帧沿用父类 messageToValue(父类已映射 delta.reasoning_content→reasoning、
 * prompt_tokens_details.cached_tokens→cached_tokens,豆包帧同形),无需覆写。
 *
 * 仅当帧/多模态形态有厂商差异时才需新增此类文件;纯 OpenAI 兼容接入只配置文本能力行,不新增文件。
 *
 * 文件名 `llm.volcengine.js` 即注册键 `volcengine`。
 */

import OpenAIProvider from './llm.openai.js'

export default class VolcengineProvider extends OpenAIProvider {
  /** @returns {string} */
  defaultBaseUrl() {
    return 'https://ark.cn-beijing.volces.com/api/v3'
  }

  /**
   * @protected
   * @returns {boolean}
   */
  supportsVideo() {
    return true
  }

  /** Ark 的成功 usage 帧必须携带可对账的响应 id 与实际 service_tier。 */
  messageToValue(frame) {
    if (frame && typeof frame === 'object' && frame.usage) {
      if (typeof frame.id !== 'string' || frame.id.length === 0) {
        throw new Error('[volcengine] 成功 usage 帧缺 provider request id')
      }
      if (typeof frame.service_tier !== 'string' || frame.service_tier.length === 0) {
        throw new Error('[volcengine] 成功 usage 帧缺 service_tier')
      }
      const promptDetails = requiredDetails(frame.usage.prompt_tokens_details, 'prompt_tokens_details')
      const completionDetails = requiredDetails(frame.usage.completion_tokens_details, 'completion_tokens_details')
      requiredArkUsageInteger(promptDetails.cached_tokens, 'prompt_tokens_details.cached_tokens')
      requiredArkUsageInteger(completionDetails.reasoning_tokens, 'completion_tokens_details.reasoning_tokens')
    }
    return super.messageToValue(frame)
  }

  /**
   * 复用父类 text+image 归一,再消费 videos → video_url 块。
   * @protected
   * @param {import('../index.js').ChatRequest} req
   * @returns {Array<{ role:string, content:any }>}
   */
  _toOpenAIMessages(req) {
    const messages = super._toOpenAIMessages(req)
    const videos = this.buildMediaParts(req.videos, 'video')
    if (videos.length && messages.length) {
      const last = messages[messages.length - 1]
      /** @type {Array<any>} */
      const parts = Array.isArray(last.content)
        ? last.content
        : [{ type: 'text', text: typeof last.content === 'string' ? last.content : '' }]
      for (const vid of videos) {
        const url = vid.url ?? `data:${vid.mime};base64,${vid.data}`
        parts.push({ type: 'video_url', video_url: { url } })
      }
      last.content = parts
    }
    return messages
  }
}

function requiredArkUsageInteger(value, field) {
  if (!Number.isSafeInteger(value) || value < 0) {
    throw new Error(`[volcengine] usage.${field} 必须是非负安全整数`)
  }
}

function requiredDetails(value, field) {
  if (!value || Object.getPrototypeOf(value) !== Object.prototype) {
    throw new Error(`[volcengine] usage.${field} 必须是 object`)
  }
  return value
}