llm.gemini.js 5.69 KB
/**
 * Gemini 子类 —— streamGenerateContent?alt=sse 原生协议。继承 LLMBase(协议与 OpenAI 异构)。
 *
 * 厂商专有概念全部圈在本文件:`x-goog-api-key` 认证(无 authorization)、`contents`/`systemInstruction`
 * 形态、image/video 的 `inline_data`(base64)/`file_data`(url)多模态块、`candidates[].content.parts`
 * 与 `usageMetadata` 帧解析。代理不在包内探测,经 lease.baseUrl 注入承载。
 *
 * 流式/重试/trace/usage 累积仍全在 LLMBase;`alt=sse` 输出标准 `data: {json}` 帧,iterateSse 直接分帧。
 */

import { LLMBase } from '../base.js'

const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'

export default class GeminiProvider extends LLMBase {
  /**
   * @protected
   * @returns {string}
   */
  defaultBaseUrl() {
    return DEFAULT_BASE_URL
  }

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

  /**
   * @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
    return this.request({
      req,
      opts,
      affinity_key: opts.trace_ctx?.session_id,
      doFetch: (lease, { signal }) => {
        const base = lease.base_url ?? this.defaultBaseUrl()
        const { systemInstruction, contents } = this._toGeminiContents(req)
        /** @type {Record<string, any>} */
        const body = { contents }
        if (systemInstruction) body.systemInstruction = systemInstruction
        if (req.temperature != null) body.generationConfig = { temperature: req.temperature }
        this.applyResponseFormat(body, {}, req.response_format)
        return this.httpFetch(`${base}/models/${model}:streamGenerateContent?alt=sse`, {
          method: 'POST',
          signal,
          headers: { 'content-type': 'application/json', 'x-goog-api-key': lease.api_key },
          body: JSON.stringify(body),
        })
      },
    })
  }

  /**
   * Gemini structured output 分派表:
   *   json_object → responseMimeType='application/json'(无 schema 约束)。
   *   json_schema → responseMimeType + responseSchema。
   * @protected
   * @returns {Record<string, (body:Record<string,any>, headers:Record<string,string>, rf:{schema?:object, name?:string}) => void>}
   */
  responseFormatHandlers() {
    return {
      json_object: (body) => {
        body.generationConfig = {
          ...(body.generationConfig ?? {}),
          responseMimeType: 'application/json',
        }
      },
      json_schema: (body, _headers, rf) => {
        body.generationConfig = {
          ...(body.generationConfig ?? {}),
          responseMimeType: 'application/json',
          responseSchema: rf.schema,
        }
      },
    }
  }

  /**
   * messages → { systemInstruction?, contents }。system 累积为 systemInstruction;
   * 其余转 { role:'user'|'model', parts }。image/video 追加到最后一条 user parts。
   * @private
   * @param {import('../index.js').ChatRequest} req
   * @returns {{ systemInstruction?:object, contents:Array<any> }}
   */
  _toGeminiContents(req) {
    /** @type {string[]} */
    const systemTexts = []
    /** @type {Array<any>} */
    const contents = []
    for (const m of req.messages) {
      if (m.role === 'system') {
        systemTexts.push(String(m.content))
        continue
      }
      contents.push({
        role: m.role === 'assistant' ? 'model' : 'user',
        parts: [{ text: String(m.content) }],
      })
    }
    if (contents.length) {
      const last = contents[contents.length - 1]
      for (const img of this.buildMediaParts(req.images, 'image')) last.parts.push(mediaPart(img))
      for (const vid of this.buildMediaParts(req.videos, 'video')) last.parts.push(mediaPart(vid))
    }
    const out = { contents }
    if (systemTexts.length) out.systemInstruction = { parts: [{ text: systemTexts.join('\n') }] }
    return out
  }

  /**
   * @param {any} frame Gemini SSE data 帧(已解析 JSON)。
   * @returns {import('../base.js').ParsedValue | null}
   */
  messageToValue(frame) {
    if (!frame || typeof frame !== 'object') return null
    // 同帧可同时带 candidates 文本与 usageMetadata:两者都发(数组返回),base 展开消费,杜绝丢 usage(漏计费)。
    /** @type {Array<import('../base.js').ParsedValue>} */
    const out = []
    const part = frame.candidates?.[0]?.content?.parts?.[0]
    if (typeof part?.text === 'string' && part.text.length > 0) out.push({ type: 'text', text: part.text })
    const u = frame.usageMetadata
    if (u) {
      out.push({
        type: 'usage',
        usage: {
          prompt_tokens: optionalUsageInteger(u.promptTokenCount, 'promptTokenCount'),
          completion_tokens: optionalUsageInteger(u.candidatesTokenCount, 'candidatesTokenCount'),
          total_tokens: optionalUsageInteger(u.totalTokenCount, 'totalTokenCount'),
          cached_tokens: optionalUsageInteger(u.cachedContentTokenCount, 'cachedContentTokenCount'),
        },
      })
    }
    return out.length ? out : null
  }
}

function optionalUsageInteger(value, field) {
  if (value === undefined) return 0
  if (!Number.isSafeInteger(value) || value < 0) {
    throw new Error(`[gemini] usageMetadata.${field} 必须是非负安全整数`)
  }
  return value
}

/**
 * MediaPart → Gemini part:url → file_data;base64 字节 → inline_data(mime 来自魔数归一)。
 * @param {import('../base.js').MediaPart} part
 * @returns {object}
 */
function mediaPart(part) {
  return part.url
    ? { file_data: { file_uri: part.url, mime_type: part.mime } }
    : { inline_data: { mime_type: part.mime, data: part.data } }
}