1
13
14import type {
15 ChatChunk,
16 ChatMessage,
17 ChatRequest,
18 ChatResult,
19 EmbedRequest,
20 EmbedResult,
21 ModelInfo,
22 ProviderAdapter,
23 ToolCall,
24} from "../types.js";
25import { httpJson, ProviderHttpError, sseData, type HttpOptions } from "./http.js";
26
27export interface GoogleOptions extends HttpOptions {
28 baseUrl?: string;
29 apiKey: string;
30 now?: () => number;
31}
32
33interface GeminiPart {
34 text?: string;
35 functionCall?: { name: string; args?: Record<string, unknown> };
36 functionResponse?: { name: string; response?: Record<string, unknown> };
37}
38interface GeminiSseEvent {
39 candidates?: Array<{
40 content?: { role?: string; parts?: GeminiPart[] };
41 finishReason?: string;
42 }>;
43 usageMetadata?: {
44 promptTokenCount?: number;
45 candidatesTokenCount?: number;
46 totalTokenCount?: number;
47 };
48 error?: { message?: string };
49}
50interface GeminiBatchEmbedResponse {
51 embeddings?: Array<{ values: number[] }>;
52}
53
54export class GoogleAdapter implements ProviderAdapter {
55 readonly id = "google" as const;
56 private readonly baseUrl: string;
57 private readonly apiKey: string;
58 private readonly http: HttpOptions;
59 private readonly now: () => number;
60
61 constructor(options: GoogleOptions) {
62 this.baseUrl = (options.baseUrl ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "");
63 this.apiKey = options.apiKey;
64 this.http = {
65 ...(options.fetchImpl !== undefined ? { fetchImpl: options.fetchImpl } : {}),
66 ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
67 ...(options.idleMs !== undefined ? { idleMs: options.idleMs } : {}),
68 };
69 this.now = options.now ?? Date.now;
70 }
71
72 private headers(): Record<string, string> {
73 return { "content-type": "application/json", "x-goog-api-key": this.apiKey };
74 }
75
76 private modelUrl(model: string, action: string): string {
77 return `${this.baseUrl}/models/${encodeURIComponent(model)}:${action}`;
78 }
79
80 async models(): Promise<ModelInfo[]> {
81 try {
82 const res = await httpJson<{ models?: Array<{ name?: string }> }>(
83 `${this.baseUrl}/models`,
84 { method: "GET", headers: this.headers() },
85 this.http,
86 );
87 return (res.models ?? []).map((m) => {
88 const id = (m.name ?? "").replace(/^models\
89 return toModelInfo(id, "google");
90 });
91 } catch {
92 return [];
93 }
94 }
95
96 async *chat(req: ChatRequest): AsyncIterable<ChatChunk> {
97 const system = extractSystem(req.messages);
98 const contents = req.messages
99 .filter((m) => m.role !== "system")
100 .map(mapContent)
101 .filter((c) => c !== null);
102
103 const body: Record<string, unknown> = {
104 contents,
105 generationConfig: {
106 maxOutputTokens: req.maxTokens,
107 temperature: req.temperature,
108 },
109 };
110 if (system) body.systemInstruction = { parts: [{ text: system }] };
111 if (req.tools && req.tools.length > 0) {
112 body.tools = [
113 {
114 functionDeclarations: req.tools.map((t) => ({
115 name: t.name,
116 description: t.description,
117 parameters: t.parameters,
118 })),
119 },
120 ];
121 body.toolConfig = { functionCallingConfig: { mode: mapToolMode(req.toolChoice) } };
122 }
123
124 const controller = new AbortController();
125 const timer = setTimeout(() => controller.abort(), this.http.timeoutMs ?? 60_000);
126
127 const onCallerAbort = () => controller.abort();
128 if (req.signal) {
129 if (req.signal.aborted) controller.abort();
130 else req.signal.addEventListener("abort", onCallerAbort, { once: true });
131 }
132 let response: Response;
133 try {
134 const fetchImpl = this.http.fetchImpl ?? fetch;
135 response = await fetchImpl(`${this.modelUrl(req.model, "streamGenerateContent")}?alt=sse`, {
136 method: "POST",
137 headers: this.headers(),
138 body: JSON.stringify(body),
139 signal: controller.signal,
140 });
141 } finally {
142 clearTimeout(timer);
143 if (req.signal) req.signal.removeEventListener("abort", onCallerAbort);
144 }
145 if (!response.ok) {
146 const text = await response.text().catch(() => "");
147 throw new ProviderHttpError(response.status, `HTTP ${response.status}: ${text.slice(0, 200)}`, response.status === 429 || response.status >= 500);
148 }
149
150 let promptTokens = 0;
151 let outputTokens = 0;
152 let finishReason: ChatResult["finishReason"] = "stop";
153 let toolIndex = 0;
154 let hadToolCall = false;
155
156 try {
157 for await (const event of sseData<GeminiSseEvent>(response, this.http, req.signal)) {
158 if (event.error) {
159 throw new ProviderHttpError(502, `provider error: ${event.error.message ?? "unknown"}`, true);
160 }
161 for (const candidate of event.candidates ?? []) {
162 for (const part of candidate.content?.parts ?? []) {
163 if (typeof part.text === "string" && part.text.length > 0) {
164 yield { delta: part.text, done: false };
165 }
166 if (part.functionCall) {
167 hadToolCall = true;
168 yield {
169 toolCallDelta: {
170 id: `call_${toolIndex++}`,
171 name: part.functionCall.name,
172 arguments: part.functionCall.args ?? {},
173 },
174 done: false,
175 };
176 }
177 }
178 if (candidate.finishReason) {
179 finishReason = mapFinish(candidate.finishReason);
180 }
181 }
182 if (event.usageMetadata) {
183 promptTokens = event.usageMetadata.promptTokenCount ?? 0;
184 outputTokens = event.usageMetadata.candidatesTokenCount ?? 0;
185 }
186 }
187 } catch (err) {
188
189
190
191 if (err instanceof ProviderHttpError || (err instanceof Error && err.name === "AbortError")) throw err;
192 throw new ProviderHttpError(502, err instanceof Error ? err.message : "stream failed", true);
193 }
194
195
196
197
198 if (hadToolCall && finishReason !== "error") {
199 finishReason = "tool_calls";
200 }
201
202 yield {
203 usage: { in: promptTokens, out: outputTokens, total: promptTokens + outputTokens },
204 finishReason,
205 done: true,
206 };
207 }
208
209 async embed(req: EmbedRequest): Promise<EmbedResult> {
210 const started = this.now();
211 const res = await httpJson<GeminiBatchEmbedResponse>(
212 this.modelUrl(req.model, "batchEmbedContents"),
213 {
214 method: "POST",
215 headers: this.headers(),
216 body: JSON.stringify({
217 requests: req.texts.map((t) => ({ model: `models/${req.model}`, content: { parts: [{ text: t }] } })),
218 }),
219 },
220 { ...this.http, timeoutMs: this.http.timeoutMs ?? 10_000 },
221 );
222 const vectors = (res.embeddings ?? []).map((e) => e.values);
223 const dimension = vectors[0]?.length ?? 0;
224 return {
225 model: req.model,
226 dimension,
227 vectors,
228 usage: { tokens: 0 },
229 latencyMs: this.now() - started,
230 provider: this.id,
231 };
232 }
233
234 async health(): Promise<{ ok: boolean; latencyMs: number }> {
235 const started = this.now();
236 try {
237 await httpJson(`${this.baseUrl}/models`, { method: "GET", headers: this.headers() }, { ...this.http, timeoutMs: 5_000 });
238 return { ok: true, latencyMs: this.now() - started };
239 } catch {
240 return { ok: false, latencyMs: this.now() - started };
241 }
242 }
243}
244
245
246
247function extractSystem(messages: ChatMessage[]): string | null {
248 const parts: string[] = [];
249 for (const m of messages) {
250 if (m.role === "system" && typeof m.content === "string") parts.push(m.content);
251 }
252 return parts.length > 0 ? parts.join("\n\n") : null;
253}
254
255function mapContent(msg: ChatMessage): { role: "user" | "model"; parts: GeminiPart[] } | null {
256 const role: "user" | "model" = msg.role === "assistant" ? "model" : "user";
257 const parts: GeminiPart[] = [];
258 if (typeof msg.content === "string" && msg.content.length > 0) {
259 parts.push({ text: msg.content });
260 }
261 if (msg.role === "assistant" && msg.toolCalls) {
262 for (const tc of msg.toolCalls) {
263 parts.push({ functionCall: { name: tc.name, args: tc.arguments } });
264 }
265 }
266 if (msg.role === "tool") {
267 parts.push({
268 functionResponse: {
269 name: msg.name ?? "",
270 response: { id: msg.toolCallId ?? "", result: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content) },
271 },
272 });
273 }
274 return parts.length > 0 ? { role, parts } : null;
275}
276
277function mapToolMode(tc: ChatRequest["toolChoice"]): "AUTO" | "ANY" | "NONE" {
278 if (tc === "none") return "NONE";
279 if (tc && typeof tc === "object") return "ANY";
280 return "AUTO";
281}
282
283function mapFinish(reason: string): ChatResult["finishReason"] {
284 if (reason === "STOP") return "stop";
285 if (reason === "MAX_TOKENS" || reason === "RECITATION") return "length";
286 if (reason === "SAFETY") return "error";
287 return "stop";
288}
289
290function toModelInfo(id: string, provider: "google"): ModelInfo {
291 const known: Partial<Record<string, { in: number; out: number; ctx: number; maxOut: number; embed?: number }>> = {
292 "gemini-2.0-flash": { in: 0.1, out: 0.4, ctx: 1_048_576, maxOut: 8_192 },
293 "gemini-1.5-pro": { in: 1.25, out: 5, ctx: 2_097_152, maxOut: 8_192 },
294 "text-embedding-004": { in: 0.025, out: 0, ctx: 2_048, maxOut: 0, embed: 768 },
295 };
296 const k = known[id];
297 const embed = k?.embed !== undefined;
298 return {
299 id,
300 provider,
301 kind: embed ? "embed" : "chat",
302 contextWindow: k?.ctx ?? 32_768,
303 maxOutput: k?.maxOut ?? 8_192,
304 toolCalls: !embed,
305 vision: !embed,
306 pricePer1kIn: k?.in ?? 0,
307 pricePer1kOut: k?.out ?? 0,
308 dimension: k?.embed ?? 0,
309 };
310}
311