/** * 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} */ 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} */ 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, headers:Record, 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 }} */ _toGeminiContents(req) { /** @type {string[]} */ const systemTexts = [] /** @type {Array} */ 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} */ 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 } } }