| import type { |
| ChatMessage, |
| ChatCompletionResponse, |
| ChatCompletionChunk, |
| ChatToolCall, |
| ChatToolChoice, |
| ChatToolDefinition, |
| TokenUsage, |
| } from '@freellmapi/shared/types.js'; |
| import { BaseProvider, type CompletionOptions } from './base.js'; |
| import { contentToString } from '../lib/content.js'; |
|
|
| const API_BASE = 'https://generativelanguage.googleapis.com/v1beta'; |
|
|
| interface GeminiPart { |
| text?: string; |
| thoughtSignature?: string; |
| functionCall?: { |
| id?: string; |
| name?: string; |
| args?: unknown; |
| }; |
| functionResponse?: { |
| id?: string; |
| name?: string; |
| response?: unknown; |
| }; |
| } |
|
|
| interface GeminiCandidate { |
| content?: { parts?: GeminiPart[] }; |
| finishReason?: string; |
| } |
|
|
| interface GeminiResponse { |
| candidates?: GeminiCandidate[]; |
| usageMetadata?: { |
| promptTokenCount?: number; |
| candidatesTokenCount?: number; |
| totalTokenCount?: number; |
| }; |
| } |
|
|
| function safeParseObject(raw: string): Record<string, unknown> { |
| try { |
| const parsed = JSON.parse(raw) as unknown; |
| if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { |
| return parsed as Record<string, unknown>; |
| } |
| return { value: parsed }; |
| } catch { |
| return { value: raw }; |
| } |
| } |
|
|
| function normalizeGeminiArgs(args: unknown): string { |
| if (typeof args === 'string') return args; |
| return JSON.stringify(args ?? {}); |
| } |
|
|
| function toGeminiFinishReason(finishReason?: string): string { |
| const r = (finishReason ?? '').toUpperCase(); |
| if (!r) return 'stop'; |
| if (r === 'MAX_TOKENS') return 'length'; |
| if (r === 'SAFETY' || r === 'RECITATION' || r === 'BLOCKLIST' || r === 'PROHIBITED_CONTENT' || r === 'SPII') { |
| return 'content_filter'; |
| } |
| return 'stop'; |
| } |
|
|
| |
| |
| |
| const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([ |
| '$schema', '$id', '$ref', '$defs', '$comment', |
| 'definitions', |
| 'exclusiveMinimum', 'exclusiveMaximum', |
| 'patternProperties', 'unevaluatedProperties', 'unevaluatedItems', |
| 'if', 'then', 'else', |
| 'contentEncoding', 'contentMediaType', 'contentSchema', |
| 'dependentRequired', 'dependentSchemas', |
| 'additionalProperties', |
| ]); |
|
|
| export function sanitizeForGemini(schema: unknown): unknown { |
| if (Array.isArray(schema)) { |
| return schema.map(sanitizeForGemini); |
| } |
| if (schema && typeof schema === 'object') { |
| const out: Record<string, unknown> = {}; |
| for (const [k, v] of Object.entries(schema as Record<string, unknown>)) { |
| if (GEMINI_UNSUPPORTED_SCHEMA_KEYS.has(k)) continue; |
| out[k] = sanitizeForGemini(v); |
| } |
| return out; |
| } |
| return schema; |
| } |
|
|
| function toGeminiTools(tools?: ChatToolDefinition[]): Array<{ functionDeclarations: Array<Record<string, unknown>> }> | undefined { |
| if (!tools || tools.length === 0) return undefined; |
|
|
| return [{ |
| functionDeclarations: tools.map(t => ({ |
| name: t.function.name, |
| description: t.function.description, |
| parameters: sanitizeForGemini(t.function.parameters), |
| })), |
| }]; |
| } |
|
|
| function toGeminiToolConfig(toolChoice?: ChatToolChoice): { functionCallingConfig: Record<string, unknown> } | undefined { |
| if (!toolChoice) return undefined; |
|
|
| if (typeof toolChoice === 'string') { |
| const mode = |
| toolChoice === 'none' |
| ? 'NONE' |
| : toolChoice === 'required' |
| ? 'ANY' |
| : 'AUTO'; |
| return { functionCallingConfig: { mode } }; |
| } |
|
|
| return { |
| functionCallingConfig: { |
| mode: 'ANY', |
| allowedFunctionNames: [toolChoice.function.name], |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| function toGeminiContents(messages: ChatMessage[]) { |
| const systemMessages = messages |
| .filter(m => m.role === 'system') |
| .map(m => contentToString(m.content)) |
| .filter(s => s.length > 0); |
|
|
| const toolNameByCallId = new Map<string, string>(); |
| for (const m of messages) { |
| for (const tc of m.tool_calls ?? []) { |
| toolNameByCallId.set(tc.id, tc.function.name); |
| } |
| } |
|
|
| const contents = messages |
| .filter(m => m.role !== 'system') |
| .map((m): { role: 'user' | 'model'; parts: GeminiPart[] } | null => { |
| if (m.role === 'assistant') { |
| const parts: GeminiPart[] = []; |
|
|
| const assistantText = contentToString(m.content); |
| if (assistantText.length > 0) { |
| parts.push({ text: assistantText }); |
| } |
|
|
| for (const call of m.tool_calls ?? []) { |
| parts.push({ |
| thoughtSignature: call.thought_signature, |
| functionCall: { |
| id: call.id, |
| name: call.function.name, |
| args: safeParseObject(call.function.arguments), |
| }, |
| }); |
| } |
|
|
| if (parts.length === 0) return null; |
| return { |
| role: 'model', |
| parts, |
| }; |
| } |
|
|
| if (m.role === 'tool') { |
| const toolCallId = m.tool_call_id; |
| if (!toolCallId) return null; |
|
|
| const toolName = m.name ?? toolNameByCallId.get(toolCallId) ?? 'tool'; |
| const response = safeParseObject(contentToString(m.content)); |
|
|
| return { |
| role: 'user', |
| parts: [{ |
| functionResponse: { |
| id: toolCallId, |
| name: toolName, |
| response, |
| }, |
| }], |
| }; |
| } |
|
|
| return { |
| role: 'user', |
| parts: [{ text: contentToString(m.content) }], |
| }; |
| }) |
| .filter((entry): entry is { role: 'user' | 'model'; parts: GeminiPart[] } => entry !== null); |
|
|
| return { |
| contents, |
| systemInstruction: systemMessages.length > 0 |
| ? { parts: [{ text: systemMessages.join('\n\n') }] } |
| : undefined, |
| }; |
| } |
|
|
| function extractToolCalls(parts: GeminiPart[] | undefined): ChatToolCall[] { |
| const calls: ChatToolCall[] = []; |
| if (!parts) return calls; |
|
|
| let fallbackIndex = 0; |
| for (const part of parts) { |
| if (!part.functionCall?.name) continue; |
|
|
| const id = part.functionCall.id ?? `call_${Date.now()}_${fallbackIndex++}`; |
| calls.push({ |
| id, |
| type: 'function', |
| function: { |
| name: part.functionCall.name, |
| arguments: normalizeGeminiArgs(part.functionCall.args), |
| }, |
| thought_signature: part.thoughtSignature, |
| }); |
| } |
|
|
| return calls; |
| } |
|
|
| function extractText(parts: GeminiPart[] | undefined): string | null { |
| if (!parts) return null; |
| const text = parts |
| .map(p => p.text ?? '') |
| .join(''); |
| return text.length > 0 ? text : null; |
| } |
|
|
| export class GoogleProvider extends BaseProvider { |
| readonly platform = 'google' as const; |
| readonly name = 'Google AI Studio'; |
|
|
| async chatCompletion( |
| apiKey: string, |
| messages: ChatMessage[], |
| modelId: string, |
| options?: CompletionOptions, |
| ): Promise<ChatCompletionResponse> { |
| const { contents, systemInstruction } = toGeminiContents(messages); |
|
|
| const body: Record<string, unknown> = { |
| contents, |
| generationConfig: { |
| temperature: options?.temperature, |
| maxOutputTokens: options?.max_tokens, |
| topP: options?.top_p, |
| }, |
| tools: toGeminiTools(options?.tools), |
| toolConfig: toGeminiToolConfig(options?.tool_choice), |
| }; |
| if (systemInstruction) body.systemInstruction = systemInstruction; |
|
|
| const url = `${API_BASE}/models/${modelId}:generateContent?key=${apiKey}`; |
| const res = await this.fetchWithTimeout(url, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }); |
|
|
| if (!res.ok) { |
| const err = await res.json().catch(() => ({})); |
| throw new Error(`Google API error ${res.status}: ${(err as any).error?.message ?? res.statusText}`); |
| } |
|
|
| const data = await res.json() as GeminiResponse; |
| const candidate = data.candidates?.[0]; |
| const parts = candidate?.content?.parts; |
| const toolCalls = extractToolCalls(parts); |
| const text = extractText(parts); |
|
|
| const usage: TokenUsage = { |
| prompt_tokens: data.usageMetadata?.promptTokenCount ?? 0, |
| completion_tokens: data.usageMetadata?.candidatesTokenCount ?? 0, |
| total_tokens: data.usageMetadata?.totalTokenCount ?? 0, |
| }; |
|
|
| return { |
| id: this.makeId(), |
| object: 'chat.completion', |
| created: Math.floor(Date.now() / 1000), |
| model: modelId, |
| choices: [{ |
| index: 0, |
| message: { |
| role: 'assistant', |
| content: text, |
| ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), |
| }, |
| finish_reason: toolCalls.length > 0 ? 'tool_calls' : toGeminiFinishReason(candidate?.finishReason), |
| }], |
| usage, |
| _routed_via: { platform: 'google', model: modelId }, |
| }; |
| } |
|
|
| async *streamChatCompletion( |
| apiKey: string, |
| messages: ChatMessage[], |
| modelId: string, |
| options?: CompletionOptions, |
| ): AsyncGenerator<ChatCompletionChunk> { |
| const { contents, systemInstruction } = toGeminiContents(messages); |
|
|
| const body: Record<string, unknown> = { |
| contents, |
| generationConfig: { |
| temperature: options?.temperature, |
| maxOutputTokens: options?.max_tokens, |
| topP: options?.top_p, |
| }, |
| tools: toGeminiTools(options?.tools), |
| toolConfig: toGeminiToolConfig(options?.tool_choice), |
| }; |
| if (systemInstruction) body.systemInstruction = systemInstruction; |
|
|
| const url = `${API_BASE}/models/${modelId}:streamGenerateContent?alt=sse&key=${apiKey}`; |
| const res = await this.fetchWithTimeout(url, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(body), |
| }); |
|
|
| if (!res.ok) { |
| const err = await res.json().catch(() => ({})); |
| throw new Error(`Google API error ${res.status}: ${(err as any).error?.message ?? res.statusText}`); |
| } |
|
|
| const reader = res.body?.getReader(); |
| if (!reader) throw new Error('No response body'); |
|
|
| const decoder = new TextDecoder(); |
| const id = this.makeId(); |
| let buffer = ''; |
| let emittedFinish = false; |
| let sawToolCalls = false; |
|
|
| const seenToolCallKeys = new Set<string>(); |
|
|
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
|
|
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop() ?? ''; |
|
|
| for (const line of lines) { |
| const trimmed = line.trim(); |
| if (!trimmed || !trimmed.startsWith('data: ')) continue; |
| const raw = trimmed.slice(6); |
| if (raw === '[DONE]') { |
| if (!emittedFinish) { |
| emittedFinish = true; |
| yield { |
| id, |
| object: 'chat.completion.chunk', |
| created: Math.floor(Date.now() / 1000), |
| model: modelId, |
| choices: [{ |
| index: 0, |
| delta: {}, |
| finish_reason: sawToolCalls ? 'tool_calls' : 'stop', |
| }], |
| }; |
| } |
| return; |
| } |
|
|
| |
| |
| |
| let chunk: GeminiResponse; |
| try { |
| chunk = JSON.parse(raw) as GeminiResponse; |
| } catch { |
| continue; |
| } |
| const candidate = chunk.candidates?.[0]; |
| const parts = candidate?.content?.parts ?? []; |
|
|
| const text = extractText(parts); |
| const toolCalls = extractToolCalls(parts).filter(call => { |
| const key = `${call.id}:${call.function.name}:${call.function.arguments}`; |
| if (seenToolCallKeys.has(key)) return false; |
| seenToolCallKeys.add(key); |
| return true; |
| }); |
|
|
| if ((text && text.length > 0) || toolCalls.length > 0) { |
| sawToolCalls = sawToolCalls || toolCalls.length > 0; |
| yield { |
| id, |
| object: 'chat.completion.chunk', |
| created: Math.floor(Date.now() / 1000), |
| model: modelId, |
| choices: [{ |
| index: 0, |
| delta: { |
| ...(text ? { content: text } : {}), |
| ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}), |
| }, |
| finish_reason: null, |
| }], |
| }; |
| } |
|
|
| if (candidate?.finishReason && !emittedFinish) { |
| emittedFinish = true; |
| yield { |
| id, |
| object: 'chat.completion.chunk', |
| created: Math.floor(Date.now() / 1000), |
| model: modelId, |
| choices: [{ |
| index: 0, |
| delta: {}, |
| finish_reason: sawToolCalls ? 'tool_calls' : toGeminiFinishReason(candidate.finishReason), |
| }], |
| }; |
| return; |
| } |
| } |
| } |
|
|
| if (!emittedFinish) { |
| yield { |
| id, |
| object: 'chat.completion.chunk', |
| created: Math.floor(Date.now() / 1000), |
| model: modelId, |
| choices: [{ |
| index: 0, |
| delta: {}, |
| finish_reason: sawToolCalls ? 'tool_calls' : 'stop', |
| }], |
| }; |
| } |
| } |
|
|
| async validateKey(apiKey: string): Promise<boolean> { |
| |
| |
| const res = await this.fetchWithTimeout( |
| `${API_BASE}/models?key=${apiKey}`, |
| { method: 'GET' }, |
| 10000, |
| ); |
| return res.status !== 401 && res.status !== 403; |
| } |
| } |
|
|