| 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;
|
| inlineData?: {
|
| mimeType: string;
|
| data: 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', 'dependencies',
|
| 'additionalProperties',
|
| 'examples', 'const', 'readOnly', 'writeOnly',
|
| 'uniqueItems',
|
| 'not', 'allOf', 'oneOf',
|
| 'prefixItems',
|
| 'contains', 'minContains', 'maxContains',
|
| 'propertyNames',
|
| 'multipleOf',
|
| 'deprecated',
|
| ]);
|
|
|
| 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],
|
| },
|
| };
|
| }
|
|
|
| const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
|
|
|
|
|
| function extractImageUrl(block: unknown): string | undefined {
|
| const iu = (block as { image_url?: unknown })?.image_url;
|
| if (typeof iu === 'string') return iu;
|
| if (iu && typeof (iu as { url?: unknown }).url === 'string') return (iu as { url: string }).url;
|
| return undefined;
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| async function imageUrlToInlineData(url: string): Promise<{ mimeType: string; data: string } | null> {
|
| const dataMatch = /^data:([^;,]+)?(;base64)?,(.*)$/s.exec(url);
|
| if (dataMatch) {
|
| const mimeType = dataMatch[1] || 'application/octet-stream';
|
| const isBase64 = Boolean(dataMatch[2]);
|
| const payload = dataMatch[3] ?? '';
|
| const data = isBase64
|
| ? payload
|
| : Buffer.from(decodeURIComponent(payload)).toString('base64');
|
| return { mimeType, data };
|
| }
|
| if (/^https?:\/\//i.test(url)) {
|
| try {
|
| const res = await fetch(url);
|
| if (!res.ok) return null;
|
| const buf = Buffer.from(await res.arrayBuffer());
|
| if (buf.length === 0 || buf.length > MAX_IMAGE_BYTES) return null;
|
| const mimeType = res.headers.get('content-type')?.split(';')[0]?.trim() || 'image/jpeg';
|
| return { mimeType, data: buf.toString('base64') };
|
| } catch {
|
| return null;
|
| }
|
| }
|
| return null;
|
| }
|
|
|
|
|
|
|
| async function userContentToParts(content: ChatMessage['content']): Promise<GeminiPart[]> {
|
| const parts: GeminiPart[] = [];
|
| const text = contentToString(content);
|
| if (text.length > 0) parts.push({ text });
|
|
|
| if (Array.isArray(content)) {
|
| for (const block of content) {
|
| const type = (block as { type?: string })?.type;
|
| if (type !== 'image_url' && type !== 'image') continue;
|
| const url = extractImageUrl(block);
|
| if (!url) continue;
|
| const inlineData = await imageUrlToInlineData(url);
|
| if (inlineData) parts.push({ inlineData });
|
| }
|
| }
|
|
|
|
|
| if (parts.length === 0) parts.push({ text: '' });
|
| return parts;
|
| }
|
|
|
|
|
|
|
|
|
| async 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 = (await Promise.all(messages
|
| .filter(m => m.role !== 'system')
|
| .map(async (m): Promise<{ 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: await userContentToParts(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 } = await 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 } = await 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;
|
| }
|
| }
|
|
|