llm.gemini.js
5.69 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
/**
* 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 } }
}