@exvio/os-backend-core 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,919 @@
1
+ import {
2
+ ApiError,
3
+ FunctionCallingConfigMode,
4
+ GoogleGenAI,
5
+ type EmbedContentParameters,
6
+ type EmbedContentResponse,
7
+ type Content,
8
+ type GenerateContentConfig,
9
+ type GenerateContentParameters,
10
+ type GenerateContentResponse,
11
+ type GoogleGenAIOptions,
12
+ type Part,
13
+ } from '@google/genai'
14
+
15
+ import { AIError, aiErrorFromHttpStatus } from '../errors.ts'
16
+ import { isSupportedAIJSONSchema, matchesAIJSONSchema } from '../schema.ts'
17
+ import type {
18
+ AIChatOutput,
19
+ AIEmbeddingOutput,
20
+ AIFinishReason,
21
+ AIOperation,
22
+ AIMessage,
23
+ AIProvider,
24
+ AIProviderChatRequest,
25
+ AIProviderContentRequest,
26
+ AIProviderEmbedRequest,
27
+ AIProviderFactory,
28
+ AIProviderFactoryContext,
29
+ AIProviderObjectRequest,
30
+ AIProviderTextRequest,
31
+ AIResult,
32
+ AIStreamEvent,
33
+ AIUsage,
34
+ AIContentPart,
35
+ AIToolCall,
36
+ AIToolChoice,
37
+ AIToolDefinition,
38
+ } from '../types.ts'
39
+
40
+ const GEMINI_ENDPOINT = 'https://generativelanguage.googleapis.com'
41
+ const TOOL_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/
42
+
43
+ /** The narrow SDK surface used by the provider, exposed for transport-free tests. */
44
+ export interface GeminiClientLike {
45
+ readonly models: {
46
+ generateContent(parameters: GenerateContentParameters): Promise<GenerateContentResponse>
47
+ generateContentStream(
48
+ parameters: GenerateContentParameters,
49
+ ): Promise<AsyncGenerator<GenerateContentResponse, void, unknown>>
50
+ embedContent(parameters: EmbedContentParameters): Promise<EmbedContentResponse>
51
+ }
52
+ }
53
+
54
+ export type GeminiClientFactory = (options: GoogleGenAIOptions) => GeminiClientLike
55
+
56
+ export interface GeminiProviderFactoryOptions {
57
+ /** Tests inject a fake client here. Production uses the maintained Google SDK. */
58
+ readonly createClient?: GeminiClientFactory
59
+ }
60
+
61
+ /**
62
+ * Creates a stateless provider factory. `create` builds a fresh SDK client for
63
+ * every invocation, so an API key can never bleed across tenant requests.
64
+ */
65
+ export function createGeminiProviderFactory(
66
+ options: GeminiProviderFactoryOptions = {},
67
+ ): AIProviderFactory {
68
+ const createClient = options.createClient ?? ((clientOptions) => new GoogleGenAI(clientOptions))
69
+
70
+ const factory: AIProviderFactory = (context: AIProviderFactoryContext): AIProvider => {
71
+ if (!options.createClient && context.fetch !== globalThis.fetch) {
72
+ throw new AIError({
73
+ code: 'invalid_config',
74
+ message: 'Gemini does not support the configured custom fetch transport',
75
+ provider: context.name,
76
+ })
77
+ }
78
+ const apiKey = context.config.apiKey?.trim()
79
+ if (!apiKey) {
80
+ throw new AIError({
81
+ code: 'not_configured',
82
+ message: 'Gemini is not configured',
83
+ provider: context.name,
84
+ })
85
+ }
86
+
87
+ // The Gemini adapter intentionally accepts only Google's API origin. A
88
+ // configurable arbitrary base URL would forward the tenant API key to it.
89
+ const endpoint = normalizeGeminiEndpoint(
90
+ context.config.endpoint ?? context.config.baseUrl,
91
+ context.name,
92
+ )
93
+ const client = createClient({
94
+ apiKey,
95
+ ...(endpoint ? { httpOptions: { baseUrl: endpoint } } : {}),
96
+ })
97
+ return new GeminiProvider(client, context.name)
98
+ }
99
+ Object.defineProperty(factory, 'defaultEndpoint', {
100
+ value: GEMINI_ENDPOINT,
101
+ enumerable: true,
102
+ writable: false,
103
+ configurable: false,
104
+ })
105
+ return Object.freeze(factory)
106
+ }
107
+
108
+ export const geminiProviderFactory = createGeminiProviderFactory()
109
+
110
+ /** Convenient constructor matching the other built-in provider subpaths. */
111
+ export function createGeminiProvider(): AIProviderFactory {
112
+ return geminiProviderFactory
113
+ }
114
+
115
+ class GeminiProvider implements AIProvider {
116
+ readonly name: string
117
+ readonly capabilities = Object.freeze({
118
+ text: true,
119
+ content: true,
120
+ chat: true,
121
+ streaming: true,
122
+ object: true,
123
+ embedding: true,
124
+ tools: true,
125
+ })
126
+
127
+ constructor(
128
+ private readonly client: GeminiClientLike,
129
+ private readonly provider: string,
130
+ ) {
131
+ this.name = provider
132
+ }
133
+
134
+ async generateText(request: AIProviderTextRequest): Promise<AIResult<string>> {
135
+ assertRequestBase(request, this.provider)
136
+ if (typeof request.prompt !== 'string' || request.prompt.length === 0) {
137
+ throw invalidRequest(this.provider, request.model, 'Gemini prompt must not be empty')
138
+ }
139
+
140
+ return this.generateTextLike(request, request.prompt)
141
+ }
142
+
143
+ async generateContent(request: AIProviderContentRequest): Promise<AIResult<string>> {
144
+ assertRequestBase(request, this.provider)
145
+ if (!Array.isArray(request.content) || request.content.length === 0) {
146
+ throw invalidRequest(this.provider, request.model, 'Gemini content must not be empty')
147
+ }
148
+
149
+ return this.generateTextLike(request, [{ role: 'user', parts: toGeminiParts(request.content) }])
150
+ }
151
+
152
+ async generateChat(request: AIProviderChatRequest): Promise<AIResult<AIChatOutput>> {
153
+ assertRequestBase(request, this.provider)
154
+ validateChat(request, this.provider)
155
+
156
+ try {
157
+ const response = await this.client.models.generateContent({
158
+ model: request.model,
159
+ contents: toGeminiMessages(request.messages, this.provider, request.model),
160
+ config: buildGenerationConfig(request, buildToolConfig(request, this.provider)),
161
+ })
162
+ const toolCalls = extractToolCalls(
163
+ response,
164
+ request.tools ?? [],
165
+ this.provider,
166
+ request.model,
167
+ )
168
+ const text = extractText(response)
169
+ const finishReason = normalizeFinishReason(response, toolCalls.length > 0)
170
+
171
+ if (finishReason === 'content_filter') {
172
+ throw contentFiltered(this.provider, response.modelVersion ?? request.model, 'chat')
173
+ }
174
+
175
+ if (text.trim().length === 0 && toolCalls.length === 0) {
176
+ throw emptyResponse(this.provider, request.model)
177
+ }
178
+
179
+ return {
180
+ output: { text, toolCalls },
181
+ ...responseMetadata(response, this.provider, request.model, finishReason),
182
+ }
183
+ } catch (error) {
184
+ throw mapGeminiError(error, this.provider, request.model, request.signal)
185
+ }
186
+ }
187
+
188
+ async *streamChat(request: AIProviderChatRequest): AsyncIterable<AIStreamEvent> {
189
+ assertRequestBase(request, this.provider)
190
+ validateChat(request, this.provider)
191
+
192
+ let emitted = false
193
+ let text = ''
194
+ const toolCalls: AIToolCall[] = []
195
+ const seenToolCallIds = new Set<string>()
196
+ let lastResponse: GenerateContentResponse | undefined
197
+ let promptBlocked = false
198
+
199
+ try {
200
+ const stream = await this.client.models.generateContentStream({
201
+ model: request.model,
202
+ contents: toGeminiMessages(request.messages, this.provider, request.model),
203
+ config: buildGenerationConfig(request, buildToolConfig(request, this.provider)),
204
+ })
205
+
206
+ for await (const chunk of stream) {
207
+ lastResponse = chunk
208
+ promptBlocked ||= isPromptBlocked(chunk)
209
+ const delta = extractText(chunk)
210
+ if (delta.length > 0) {
211
+ emitted = true
212
+ text += delta
213
+ yield { type: 'text-delta', delta }
214
+ }
215
+
216
+ const chunkCalls = extractToolCalls(
217
+ chunk,
218
+ request.tools ?? [],
219
+ this.provider,
220
+ request.model,
221
+ toolCalls.length,
222
+ )
223
+ for (const toolCall of chunkCalls) {
224
+ if (seenToolCallIds.has(toolCall.id)) {
225
+ throw invalidToolArguments(this.provider, request.model)
226
+ }
227
+ seenToolCallIds.add(toolCall.id)
228
+ emitted = true
229
+ toolCalls.push(toolCall)
230
+ yield { type: 'tool-call', toolCall }
231
+ }
232
+ }
233
+
234
+ if (!lastResponse) {
235
+ throw new AIError({
236
+ code: 'invalid_response',
237
+ message: 'Gemini stream ended without a response',
238
+ provider: this.provider,
239
+ model: request.model,
240
+ })
241
+ }
242
+
243
+ const finishReason = promptBlocked
244
+ ? 'content_filter'
245
+ : normalizeFinishReason(lastResponse, toolCalls.length > 0)
246
+ if (finishReason === 'content_filter') {
247
+ throw contentFiltered(this.provider, lastResponse.modelVersion ?? request.model, 'stream')
248
+ }
249
+ if (!emitted) {
250
+ throw emptyResponse(this.provider, request.model)
251
+ }
252
+
253
+ yield {
254
+ type: 'finish',
255
+ result: {
256
+ output: { text, toolCalls },
257
+ ...responseMetadata(lastResponse, this.provider, request.model, finishReason),
258
+ },
259
+ }
260
+ } catch (error) {
261
+ const mapped = mapGeminiError(error, this.provider, request.model, request.signal)
262
+ if (emitted && mapped.code !== 'invalid_response' && mapped.code !== 'content_filtered') {
263
+ throw new AIError({
264
+ code: 'invalid_response',
265
+ message: 'Gemini stream was interrupted',
266
+ provider: this.provider,
267
+ model: request.model,
268
+ })
269
+ }
270
+ throw mapped
271
+ }
272
+ }
273
+
274
+ async generateObject(request: AIProviderObjectRequest<unknown>): Promise<AIResult<unknown>> {
275
+ assertRequestBase(request, this.provider)
276
+ if (typeof request.prompt !== 'string' || request.prompt.length === 0) {
277
+ throw invalidRequest(this.provider, request.model, 'Gemini prompt must not be empty')
278
+ }
279
+ if (
280
+ !isPlainRecord(request.schema.jsonSchema)
281
+ || !isJsonValue(request.schema.jsonSchema)
282
+ || !isSupportedAIJSONSchema(request.schema.jsonSchema)
283
+ ) {
284
+ throw invalidRequest(this.provider, request.model, 'Gemini structured output schema is invalid')
285
+ }
286
+
287
+ try {
288
+ const response = await this.client.models.generateContent({
289
+ model: request.model,
290
+ contents: request.prompt,
291
+ config: buildGenerationConfig(request, {
292
+ responseMimeType: 'application/json',
293
+ responseJsonSchema: request.schema.jsonSchema,
294
+ }),
295
+ })
296
+ const finishReason = normalizeFinishReason(response, false)
297
+ if (finishReason === 'content_filter') {
298
+ throw contentFiltered(this.provider, response.modelVersion ?? request.model, 'object')
299
+ }
300
+ const text = extractText(response)
301
+ if (text.trim().length === 0) throw emptyResponse(this.provider, request.model)
302
+
303
+ let decoded: unknown
304
+ try {
305
+ decoded = JSON.parse(text)
306
+ } catch {
307
+ throw structuredOutputError(this.provider, request.model)
308
+ }
309
+
310
+ return {
311
+ output: decoded,
312
+ ...responseMetadata(response, this.provider, request.model),
313
+ }
314
+ } catch (error) {
315
+ throw mapGeminiError(error, this.provider, request.model, request.signal)
316
+ }
317
+ }
318
+
319
+ async embed(request: AIProviderEmbedRequest): Promise<AIResult<AIEmbeddingOutput>> {
320
+ assertRequestBase(request, this.provider)
321
+ if (!Array.isArray(request.input) || request.input.length === 0) {
322
+ throw invalidRequest(this.provider, request.model, 'Gemini embedding input must not be empty')
323
+ }
324
+ if (request.input.some((input) => typeof input !== 'string' || input.length === 0)) {
325
+ throw invalidRequest(this.provider, request.model, 'Gemini embedding inputs must be non-empty strings')
326
+ }
327
+ if (request.dimensions !== undefined && (!Number.isSafeInteger(request.dimensions) || request.dimensions <= 0)) {
328
+ throw invalidRequest(this.provider, request.model, 'Gemini embedding dimensions must be positive')
329
+ }
330
+
331
+ try {
332
+ const response = await this.client.models.embedContent({
333
+ model: request.model,
334
+ contents: [...request.input],
335
+ config: {
336
+ abortSignal: request.signal,
337
+ httpOptions: requestHttpOptions(request.timeoutMs),
338
+ ...(request.dimensions ? { outputDimensionality: request.dimensions } : {}),
339
+ },
340
+ })
341
+ const embeddings = response.embeddings?.map((embedding) => embedding.values)
342
+ if (
343
+ !embeddings
344
+ || embeddings.length !== request.input.length
345
+ || embeddings.some((values) => (
346
+ !Array.isArray(values)
347
+ || values.length === 0
348
+ || values.some((value) => typeof value !== 'number' || !Number.isFinite(value))
349
+ ))
350
+ ) {
351
+ throw new AIError({
352
+ code: 'invalid_response',
353
+ message: 'Gemini returned invalid embeddings',
354
+ provider: this.provider,
355
+ model: request.model,
356
+ })
357
+ }
358
+
359
+ return {
360
+ output: { embeddings: embeddings as number[][] },
361
+ provider: this.provider,
362
+ model: request.model,
363
+ usage: null,
364
+ finishReason: 'stop',
365
+ requestId: requestIdFromResponse(response),
366
+ }
367
+ } catch (error) {
368
+ throw mapGeminiError(error, this.provider, request.model, request.signal)
369
+ }
370
+ }
371
+
372
+ private async generateTextLike(
373
+ request: AIProviderTextRequest | AIProviderContentRequest,
374
+ contents: GenerateContentParameters['contents'],
375
+ ): Promise<AIResult<string>> {
376
+ try {
377
+ const response = await this.client.models.generateContent({
378
+ model: request.model,
379
+ contents,
380
+ config: buildGenerationConfig(request),
381
+ })
382
+ const finishReason = normalizeFinishReason(response, false)
383
+ if (finishReason === 'content_filter') {
384
+ throw contentFiltered(
385
+ this.provider,
386
+ response.modelVersion ?? request.model,
387
+ 'prompt' in request ? 'text' : 'content',
388
+ )
389
+ }
390
+ const text = extractText(response)
391
+ if (text.trim().length === 0) throw emptyResponse(this.provider, request.model)
392
+ return {
393
+ output: text,
394
+ ...responseMetadata(response, this.provider, request.model),
395
+ }
396
+ } catch (error) {
397
+ throw mapGeminiError(error, this.provider, request.model, request.signal)
398
+ }
399
+ }
400
+ }
401
+
402
+ function normalizeGeminiEndpoint(endpoint: string | undefined, provider: string): string | undefined {
403
+ if (endpoint === undefined) return undefined
404
+
405
+ let parsed: URL
406
+ try {
407
+ parsed = new URL(endpoint)
408
+ } catch {
409
+ throw new AIError({ code: 'endpoint_not_allowed', message: 'Gemini endpoint is not allowed', provider })
410
+ }
411
+ const expected = new URL(GEMINI_ENDPOINT)
412
+ if (
413
+ parsed.protocol !== 'https:'
414
+ || parsed.username !== ''
415
+ || parsed.password !== ''
416
+ || parsed.origin !== expected.origin
417
+ || (parsed.pathname !== '' && parsed.pathname !== '/')
418
+ || parsed.search !== ''
419
+ || parsed.hash !== ''
420
+ ) {
421
+ throw new AIError({ code: 'endpoint_not_allowed', message: 'Gemini endpoint is not allowed', provider })
422
+ }
423
+ return parsed.origin
424
+ }
425
+
426
+ function assertRequestBase(
427
+ request:
428
+ | AIProviderTextRequest
429
+ | AIProviderContentRequest
430
+ | AIProviderChatRequest
431
+ | AIProviderObjectRequest
432
+ | AIProviderEmbedRequest,
433
+ provider: string,
434
+ ): void {
435
+ if (typeof request.model !== 'string' || request.model.trim().length === 0) {
436
+ throw invalidRequest(provider, '', 'Gemini model must not be empty')
437
+ }
438
+ if (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs <= 0) {
439
+ throw invalidRequest(provider, request.model, 'Gemini timeout must be a positive integer')
440
+ }
441
+ const maxOutputTokens = 'maxOutputTokens' in request ? request.maxOutputTokens : undefined
442
+ if (maxOutputTokens !== undefined && (
443
+ !Number.isSafeInteger(maxOutputTokens) || maxOutputTokens <= 0
444
+ )) {
445
+ throw invalidRequest(provider, request.model, 'Gemini maxOutputTokens must be positive')
446
+ }
447
+ const temperature = 'temperature' in request ? request.temperature : undefined
448
+ if (temperature !== undefined && !Number.isFinite(temperature)) {
449
+ throw invalidRequest(provider, request.model, 'Gemini temperature must be finite')
450
+ }
451
+ }
452
+
453
+ function buildGenerationConfig(
454
+ request: AIProviderTextRequest | AIProviderContentRequest | AIProviderChatRequest | AIProviderObjectRequest,
455
+ toolConfig: Partial<GenerateContentConfig> = {},
456
+ ): GenerateContentConfig {
457
+ const systemMessages = 'messages' in request
458
+ ? request.messages
459
+ .filter((message) => message.role === 'system')
460
+ .map((message) => message.content)
461
+ : []
462
+ const systemInstruction = [request.systemInstruction, ...systemMessages]
463
+ .filter((value): value is string => typeof value === 'string' && value.length > 0)
464
+ .join('\n\n')
465
+ return {
466
+ abortSignal: request.signal,
467
+ httpOptions: requestHttpOptions(request.timeoutMs),
468
+ ...(systemInstruction ? { systemInstruction } : {}),
469
+ ...(request.maxOutputTokens !== undefined ? { maxOutputTokens: request.maxOutputTokens } : {}),
470
+ ...(request.temperature !== undefined ? { temperature: request.temperature } : {}),
471
+ ...(request.topP !== undefined ? { topP: request.topP } : {}),
472
+ ...(request.stopSequences !== undefined ? { stopSequences: [...request.stopSequences] } : {}),
473
+ ...(request.disableThinking ? { thinkingConfig: { thinkingBudget: 0 } } : {}),
474
+ ...(request.responseFormat === 'json' ? { responseMimeType: 'application/json' } : {}),
475
+ ...toolConfig,
476
+ }
477
+ }
478
+
479
+ function requestHttpOptions(timeoutMs: number): NonNullable<GenerateContentConfig['httpOptions']> {
480
+ return {
481
+ timeout: timeoutMs,
482
+ // Retry belongs to the shared runtime. Disable the SDK's own retry loop so
483
+ // attempts, cancellation and stream replay rules remain observable.
484
+ retryOptions: { attempts: 1 },
485
+ }
486
+ }
487
+
488
+ function buildToolConfig(
489
+ request: AIProviderChatRequest,
490
+ provider: string,
491
+ ): Partial<GenerateContentConfig> {
492
+ const declarations = request.tools ?? []
493
+ if (declarations.length > 512) {
494
+ throw invalidRequest(provider, request.model, 'Gemini accepts at most 512 tools')
495
+ }
496
+ for (const declaration of declarations) validateToolDeclaration(declaration, provider, request.model)
497
+
498
+ const choice = request.toolChoice ?? 'auto'
499
+ if ((choice === 'required' || typeof choice === 'object') && declarations.length === 0) {
500
+ throw invalidRequest(provider, request.model, 'Gemini tool choice requires declared tools')
501
+ }
502
+
503
+ const tools = declarations.length > 0
504
+ ? [{
505
+ functionDeclarations: declarations.map((declaration) => ({
506
+ name: declaration.name,
507
+ ...(declaration.description ? { description: declaration.description } : {}),
508
+ parametersJsonSchema: declaration.parameters,
509
+ })),
510
+ }]
511
+ : undefined
512
+
513
+ const functionCallingConfig = toGeminiToolChoice(choice, declarations, provider, request.model)
514
+ return {
515
+ ...(tools ? { tools } : {}),
516
+ ...(tools || choice !== 'auto' ? { toolConfig: { functionCallingConfig } } : {}),
517
+ }
518
+ }
519
+
520
+ function toGeminiToolChoice(
521
+ choice: AIToolChoice,
522
+ declarations: readonly AIToolDefinition[],
523
+ provider: string,
524
+ model: string,
525
+ ) {
526
+ if (choice === 'none') return { mode: FunctionCallingConfigMode.NONE }
527
+ if (choice === 'required') return { mode: FunctionCallingConfigMode.ANY }
528
+ if (choice === 'auto') return { mode: FunctionCallingConfigMode.AUTO }
529
+
530
+ if (
531
+ typeof choice.name !== 'string'
532
+ || !declarations.some((declaration) => declaration.name === choice.name)
533
+ ) {
534
+ throw invalidRequest(provider, model, 'Gemini named tool choice is not declared')
535
+ }
536
+ return {
537
+ mode: FunctionCallingConfigMode.ANY,
538
+ allowedFunctionNames: [choice.name],
539
+ }
540
+ }
541
+
542
+ function validateToolDeclaration(declaration: AIToolDefinition, provider: string, model: string): void {
543
+ if (!TOOL_NAME_PATTERN.test(declaration.name)) {
544
+ throw invalidRequest(provider, model, 'Gemini tool name is invalid')
545
+ }
546
+ if (
547
+ !isPlainRecord(declaration.parameters)
548
+ || !isJsonValue(declaration.parameters)
549
+ || !isSupportedAIJSONSchema(declaration.parameters)
550
+ ) {
551
+ throw invalidRequest(provider, model, 'Gemini tool schema must be a JSON object')
552
+ }
553
+ }
554
+
555
+ function validateChat(request: AIProviderChatRequest, provider: string): void {
556
+ if (!Array.isArray(request.messages) || request.messages.length === 0) {
557
+ throw invalidRequest(provider, request.model, 'Gemini chat messages must not be empty')
558
+ }
559
+ const last = request.messages.at(-1)
560
+ if (last?.role !== 'user' && last?.role !== 'tool') {
561
+ throw invalidRequest(provider, request.model, 'Gemini chat must end with a user or tool message')
562
+ }
563
+ buildToolConfig(request, provider)
564
+ }
565
+
566
+ function toGeminiParts(parts: readonly AIContentPart[]): Part[] {
567
+ return parts.map((part) => part.type === 'text'
568
+ ? { text: part.text }
569
+ : { inlineData: { mimeType: part.mimeType, data: part.data } })
570
+ }
571
+
572
+ function toGeminiMessages(
573
+ messages: readonly AIMessage[],
574
+ provider: string,
575
+ model: string,
576
+ ): GenerateContentParameters['contents'] {
577
+ const toolNames = new Map<string, string>()
578
+ for (const message of messages) {
579
+ if (message.role !== 'assistant') continue
580
+ for (const call of message.toolCalls ?? []) toolNames.set(call.id, call.name)
581
+ }
582
+
583
+ const contents: Content[] = []
584
+ for (const message of messages) {
585
+ if (message.role === 'system') continue
586
+ if (message.role === 'user') {
587
+ const parts = typeof message.content === 'string'
588
+ ? [{ text: message.content }]
589
+ : toGeminiParts(message.content)
590
+ if (parts.length === 0) throw invalidRequest(provider, model, 'Gemini user message must not be empty')
591
+ contents.push({ role: 'user', parts })
592
+ continue
593
+ }
594
+
595
+ if (message.role === 'assistant') {
596
+ const parts: Part[] = []
597
+ if (message.content.length > 0) parts.push({ text: message.content })
598
+ for (const call of message.toolCalls ?? []) {
599
+ if (!TOOL_NAME_PATTERN.test(call.name) || !isPlainRecord(call.arguments) || !isJsonValue(call.arguments)) {
600
+ throw invalidRequest(provider, model, 'Gemini assistant tool call is invalid')
601
+ }
602
+ parts.push({
603
+ functionCall: {
604
+ ...(call.id ? { id: call.id } : {}),
605
+ name: call.name,
606
+ args: call.arguments as Record<string, unknown>,
607
+ },
608
+ })
609
+ }
610
+ if (parts.length === 0) throw invalidRequest(provider, model, 'Gemini assistant message must not be empty')
611
+ contents.push({ role: 'model', parts })
612
+ continue
613
+ }
614
+
615
+ const toolName = message.toolName ?? toolNames.get(message.toolCallId)
616
+ if (!toolName || !TOOL_NAME_PATTERN.test(toolName) || message.toolCallId.length === 0) {
617
+ throw invalidRequest(provider, model, 'Gemini tool result is invalid')
618
+ }
619
+ contents.push({
620
+ role: 'user',
621
+ parts: [{
622
+ functionResponse: {
623
+ id: message.toolCallId,
624
+ name: toolName,
625
+ response: { output: parseToolResult(message.content) },
626
+ },
627
+ }],
628
+ })
629
+ }
630
+ return contents
631
+ }
632
+
633
+ function parseToolResult(content: string): unknown {
634
+ try {
635
+ const value: unknown = JSON.parse(content)
636
+ return isJsonValue(value) ? value : content
637
+ } catch {
638
+ return content
639
+ }
640
+ }
641
+
642
+ function extractText(response: GenerateContentResponse): string {
643
+ const parts = response.candidates?.[0]?.content?.parts ?? []
644
+ return parts
645
+ .filter((part) => !part.thought && typeof part.text === 'string')
646
+ .map((part) => part.text as string)
647
+ .join('')
648
+ }
649
+
650
+ function extractToolCalls(
651
+ response: GenerateContentResponse,
652
+ declarations: readonly AIToolDefinition[],
653
+ provider: string,
654
+ model: string,
655
+ indexOffset = 0,
656
+ ): AIToolCall[] {
657
+ const byName = new Map(declarations.map((declaration) => [declaration.name, declaration]))
658
+ const nativeCalls = (response.candidates?.[0]?.content?.parts ?? [])
659
+ .map((part) => part.functionCall)
660
+ .filter((call): call is NonNullable<typeof call> => call !== undefined)
661
+
662
+ return nativeCalls.map((call, index) => {
663
+ if (
664
+ typeof call.name !== 'string'
665
+ || !TOOL_NAME_PATTERN.test(call.name)
666
+ || !isPlainRecord(call.args)
667
+ || !isJsonValue(call.args)
668
+ || (call.partialArgs?.length ?? 0) > 0
669
+ ) {
670
+ throw invalidToolArguments(provider, model)
671
+ }
672
+
673
+ const declaration = byName.get(call.name)
674
+ if (!declaration || !matchesAIJSONSchema(call.args, declaration.parameters)) {
675
+ throw invalidToolArguments(provider, model)
676
+ }
677
+
678
+ return {
679
+ id: typeof call.id === 'string' && call.id.length > 0
680
+ ? call.id
681
+ : `gemini_${call.name}_${indexOffset + index}`,
682
+ name: call.name,
683
+ arguments: call.args,
684
+ }
685
+ })
686
+ }
687
+
688
+ function responseMetadata(
689
+ response: GenerateContentResponse,
690
+ provider: string,
691
+ requestedModel: string,
692
+ finishReason = normalizeFinishReason(response, false),
693
+ ): Omit<AIResult<never>, 'output'> {
694
+ return {
695
+ provider,
696
+ model: nonEmpty(response.modelVersion) ?? requestedModel,
697
+ usage: normalizeUsage(response),
698
+ finishReason,
699
+ providerFinishReason: response.candidates?.[0]?.finishReason,
700
+ requestId: nonEmpty(response.responseId) ?? requestIdFromResponse(response),
701
+ }
702
+ }
703
+
704
+ function normalizeUsage(response: GenerateContentResponse): AIUsage | null {
705
+ const usage = response.usageMetadata
706
+ if (!usage) return null
707
+
708
+ const reportedInputTokens = tokenCount(usage.promptTokenCount)
709
+ const reportedOutputTokens = tokenCount(usage.candidatesTokenCount)
710
+ const reportedTotalTokens = tokenCount(usage.totalTokenCount)
711
+ const cachedInputTokens = tokenCount(usage.cachedContentTokenCount)
712
+ const reasoningTokens = tokenCount(usage.thoughtsTokenCount)
713
+ const toolInputTokens = tokenCount(usage.toolUsePromptTokenCount)
714
+ if (
715
+ reportedInputTokens === undefined
716
+ || reportedOutputTokens === undefined
717
+ || reportedTotalTokens === undefined
718
+ || (usage.cachedContentTokenCount !== undefined && cachedInputTokens === undefined)
719
+ || (usage.thoughtsTokenCount !== undefined && reasoningTokens === undefined)
720
+ || (usage.toolUsePromptTokenCount !== undefined && toolInputTokens === undefined)
721
+ ) return null
722
+
723
+ // Gemini reports thought tokens and tool-result prompt tokens outside the
724
+ // visible candidate/prompt buckets. Fold them into the billable neutral
725
+ // input/output totals while retaining reasoning as an output subset.
726
+ const inputTokens = reportedInputTokens + (toolInputTokens ?? 0)
727
+ const outputTokens = reportedOutputTokens + (reasoningTokens ?? 0)
728
+ const totalTokens = reportedTotalTokens
729
+ if (
730
+ !Number.isSafeInteger(inputTokens)
731
+ || !Number.isSafeInteger(outputTokens)
732
+ || totalTokens < inputTokens + outputTokens
733
+ || (cachedInputTokens ?? 0) > inputTokens
734
+ ) return null
735
+ return {
736
+ inputTokens,
737
+ outputTokens,
738
+ totalTokens,
739
+ ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
740
+ ...(cachedInputTokens !== undefined
741
+ ? { cacheMissInputTokens: Math.max(0, inputTokens - cachedInputTokens) }
742
+ : {}),
743
+ ...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
744
+ }
745
+ }
746
+
747
+ function normalizeFinishReason(response: GenerateContentResponse, hasToolCalls: boolean): AIFinishReason {
748
+ if (isPromptBlocked(response)) return 'content_filter'
749
+ if (hasToolCalls) return 'tool_calls'
750
+ switch (response.candidates?.[0]?.finishReason) {
751
+ case 'STOP':
752
+ return 'stop'
753
+ case 'MAX_TOKENS':
754
+ return 'length'
755
+ case 'SAFETY':
756
+ case 'RECITATION':
757
+ case 'LANGUAGE':
758
+ case 'BLOCKLIST':
759
+ case 'PROHIBITED_CONTENT':
760
+ case 'SPII':
761
+ case 'IMAGE_SAFETY':
762
+ case 'IMAGE_PROHIBITED_CONTENT':
763
+ case 'IMAGE_RECITATION':
764
+ return 'content_filter'
765
+ default:
766
+ return 'other'
767
+ }
768
+ }
769
+
770
+ function isPromptBlocked(response: GenerateContentResponse): boolean {
771
+ const reason = response.promptFeedback?.blockReason
772
+ return typeof reason === 'string'
773
+ && reason.length > 0
774
+ && reason !== 'BLOCKED_REASON_UNSPECIFIED'
775
+ }
776
+
777
+ function requestIdFromResponse(response: GenerateContentResponse | EmbedContentResponse): string | undefined {
778
+ const headers = response.sdkHttpResponse?.headers
779
+ if (!headers) return undefined
780
+ for (const [name, value] of Object.entries(headers)) {
781
+ const normalized = name.toLowerCase()
782
+ if ((normalized === 'x-request-id' || normalized === 'x-goog-request-id') && value.length > 0) {
783
+ return value
784
+ }
785
+ }
786
+ return undefined
787
+ }
788
+
789
+ function mapGeminiError(
790
+ error: unknown,
791
+ provider: string,
792
+ model: string,
793
+ signal: AbortSignal,
794
+ ): AIError {
795
+ if (error instanceof AIError) return error
796
+
797
+ if (signal.aborted) {
798
+ if (signal.reason instanceof AIError) return signal.reason
799
+ const timedOut = isNamedError(signal.reason, 'TimeoutError')
800
+ return new AIError({
801
+ code: timedOut ? 'timeout' : 'aborted',
802
+ message: timedOut ? 'Gemini request timed out' : 'Gemini request was aborted',
803
+ provider,
804
+ model,
805
+ retryable: timedOut,
806
+ })
807
+ }
808
+
809
+ if (isNamedError(error, 'AbortError')) {
810
+ return new AIError({ code: 'aborted', message: 'Gemini request was aborted', provider, model })
811
+ }
812
+ if (isNamedError(error, 'TimeoutError')) {
813
+ return new AIError({
814
+ code: 'timeout',
815
+ message: 'Gemini request timed out',
816
+ provider,
817
+ model,
818
+ retryable: true,
819
+ })
820
+ }
821
+
822
+ const status = error instanceof ApiError
823
+ ? error.status
824
+ : isPlainRecord(error) && typeof error.status === 'number'
825
+ ? error.status
826
+ : undefined
827
+ if (status !== undefined) {
828
+ if (status === 400 || status === 404 || status === 422) {
829
+ return new AIError({
830
+ code: 'invalid_request',
831
+ message: 'Gemini rejected the request',
832
+ provider,
833
+ model,
834
+ status,
835
+ })
836
+ }
837
+ return aiErrorFromHttpStatus(status, { provider, model })
838
+ }
839
+
840
+ return new AIError({
841
+ code: 'network',
842
+ message: 'Gemini provider request failed',
843
+ provider,
844
+ model,
845
+ retryable: true,
846
+ })
847
+ }
848
+
849
+ function invalidRequest(provider: string, model: string, message: string): AIError {
850
+ return new AIError({ code: 'invalid_request', message, provider, ...(model ? { model } : {}) })
851
+ }
852
+
853
+ function invalidToolArguments(provider: string, model: string): AIError {
854
+ return new AIError({
855
+ code: 'invalid_response',
856
+ message: 'Gemini returned invalid tool arguments',
857
+ provider,
858
+ model,
859
+ })
860
+ }
861
+
862
+ function emptyResponse(provider: string, model: string): AIError {
863
+ return new AIError({
864
+ code: 'invalid_response',
865
+ message: 'Gemini returned an empty response',
866
+ provider,
867
+ model,
868
+ })
869
+ }
870
+
871
+ function contentFiltered(provider: string, model: string, operation: AIOperation): AIError {
872
+ return new AIError({
873
+ code: 'content_filtered',
874
+ message: 'AI provider blocked the response for safety reasons',
875
+ provider,
876
+ model,
877
+ operation,
878
+ })
879
+ }
880
+
881
+ function structuredOutputError(provider: string, model: string): AIError {
882
+ return new AIError({
883
+ code: 'invalid_response',
884
+ message: 'Gemini returned invalid structured output',
885
+ provider,
886
+ model,
887
+ })
888
+ }
889
+
890
+ function nonEmpty(value: string | undefined): string | undefined {
891
+ return typeof value === 'string' && value.length > 0 ? value : undefined
892
+ }
893
+
894
+ function tokenCount(value: number | undefined): number | undefined {
895
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined
896
+ }
897
+
898
+ function isNamedError(value: unknown, name: string): boolean {
899
+ return value instanceof Error && value.name === name
900
+ }
901
+
902
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
903
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false
904
+ const prototype = Object.getPrototypeOf(value)
905
+ return prototype === Object.prototype || prototype === null
906
+ }
907
+
908
+ function isJsonValue(value: unknown, seen = new WeakSet<object>()): boolean {
909
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
910
+ if (typeof value === 'number') return Number.isFinite(value)
911
+ if (typeof value !== 'object') return false
912
+ if (seen.has(value)) return false
913
+ seen.add(value)
914
+ const valid = Array.isArray(value)
915
+ ? value.every((item) => isJsonValue(item, seen))
916
+ : isPlainRecord(value) && Object.values(value).every((item) => isJsonValue(item, seen))
917
+ seen.delete(value)
918
+ return valid
919
+ }