tayyabimam's picture
Fresh build completely clean of binaries
35ac985
Raw
History Blame Contribute Delete
13.8 kB
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';
}
// Google Gemini accepts only a subset of JSON Schema (~OpenAPI 3.0).
// Strip fields that opencode / other strict-JSON-Schema clients send but
// Google rejects with 400 "Unknown name '<field>'".
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],
},
};
}
// Translate OpenAI messages to Gemini format. Content may arrive as a string,
// null, or the OpenAI multimodal array envelope — flatten to string first so
// system/user/tool messages all surface as `parts: [{ text }]` for Gemini.
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;
}
// Skip malformed SSE frames instead of aborting the whole stream.
// Matches the defensive parse in openai-compat / cohere / cloudflare:
// a single corrupt chunk shouldn't take down the rest of the response.
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> {
// Transport errors propagate — health.ts marks status='error' without
// counting toward auto-disable. Only confirmed 401/403 disables a key.
const res = await this.fetchWithTimeout(
`${API_BASE}/models?key=${apiKey}`,
{ method: 'GET' },
10000,
);
return res.status !== 401 && res.status !== 403;
}
}