183 lines
5.0 KiB
TypeScript
183 lines
5.0 KiB
TypeScript
import { LLMProvider } from './LLMProvider';
|
|
import type { StreamChunk } from './StreamingTypes';
|
|
import type { Message } from '../core/AgentState';
|
|
import { ConfigManager } from '../../core/config/ConfigManager';
|
|
|
|
/**
|
|
* Google Gemini API provider implementation
|
|
*/
|
|
export class GeminiProvider extends LLMProvider {
|
|
readonly name = 'Gemini';
|
|
|
|
private apiKey: string;
|
|
private model: string;
|
|
private baseURL: string = 'https://generativelanguage.googleapis.com';
|
|
private apiEndpoint: string;
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
const configManager = ConfigManager.instance();
|
|
this.apiKey = configManager.get('general.gemini.api_key') as string;
|
|
this.model = configManager.get('general.gemini.model') as string;
|
|
this.apiEndpoint = `${this.baseURL}/v1beta/models/${this.model}`;
|
|
}
|
|
|
|
/**
|
|
* Convert internal messages to Gemini's format
|
|
*/
|
|
private convertMessages(messages: Message[], systemPrompt?: string): {
|
|
systemInstruction?: { parts: Array<{ text: string }> };
|
|
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
|
|
} {
|
|
const geminiMessages: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }> = [];
|
|
|
|
for (const msg of messages) {
|
|
let role: 'user' | 'model';
|
|
|
|
if (msg.role === 'assistant') {
|
|
role = 'model';
|
|
} else {
|
|
// Treat system and user messages as 'user' role
|
|
role = 'user';
|
|
}
|
|
|
|
geminiMessages.push({
|
|
role,
|
|
parts: [{ text: msg.content }]
|
|
});
|
|
}
|
|
|
|
const result: {
|
|
systemInstruction?: { parts: Array<{ text: string }> };
|
|
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
|
|
} = {
|
|
contents: geminiMessages
|
|
};
|
|
|
|
if (systemPrompt) {
|
|
result.systemInstruction = {
|
|
parts: [{ text: systemPrompt }]
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Parse Gemini's streaming response chunks
|
|
*/
|
|
private parseGeminiStreamChunk(chunk: string): { content?: string; isDone?: boolean } {
|
|
try {
|
|
const json = JSON.parse(chunk.trim());
|
|
|
|
// Gemini streaming format
|
|
if (json.candidates && json.candidates.length > 0) {
|
|
const candidate = json.candidates[0];
|
|
|
|
// Check if generation is finished
|
|
if (candidate.finishReason && candidate.finishReason !== 'STOP') {
|
|
return { isDone: true };
|
|
}
|
|
|
|
// Extract text content
|
|
const content = candidate.content?.parts?.[0]?.text;
|
|
if (content) {
|
|
return { content, isDone: false };
|
|
}
|
|
}
|
|
|
|
// Check for explicit done signal
|
|
if (json.done === true) {
|
|
return { isDone: true };
|
|
}
|
|
|
|
return {};
|
|
} catch {
|
|
return {}; // Skip invalid JSON
|
|
}
|
|
}
|
|
|
|
async *generateStream(
|
|
messages: Message[],
|
|
systemPrompt?: string,
|
|
tools?: Record<string, unknown>[]
|
|
): AsyncIterableIterator<StreamChunk> {
|
|
const { systemInstruction, contents } = this.convertMessages(messages, systemPrompt);
|
|
|
|
const requestBody: {
|
|
contents: Array<{ role: 'user' | 'model'; parts: Array<{ text: string }> }>;
|
|
generationConfig: { temperature: number; maxOutputTokens: number };
|
|
systemInstruction?: { parts: Array<{ text: string }> };
|
|
tools?: Record<string, unknown>[];
|
|
} = {
|
|
contents,
|
|
generationConfig: {
|
|
temperature: 0.7,
|
|
maxOutputTokens: 8192,
|
|
}
|
|
};
|
|
|
|
if (systemInstruction) {
|
|
requestBody.systemInstruction = systemInstruction;
|
|
}
|
|
|
|
if (tools && tools.length > 0) {
|
|
requestBody.tools = tools;
|
|
}
|
|
|
|
const response = await fetch(`${this.apiEndpoint}:streamGenerateContent?key=${this.apiKey}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Gemini API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
const reader = response.body?.getReader();
|
|
if (!reader) {
|
|
throw new Error('Failed to get response reader');
|
|
}
|
|
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// Gemini sends JSON objects separated by newlines
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || '';
|
|
|
|
for (const line of lines) {
|
|
const trimmedLine = line.trim();
|
|
if (!trimmedLine) continue;
|
|
|
|
const parseResult = this.parseGeminiStreamChunk(trimmedLine);
|
|
|
|
if (parseResult.isDone) {
|
|
yield { type: 'done', content: '' };
|
|
return;
|
|
}
|
|
|
|
if (parseResult.content) {
|
|
yield {
|
|
type: 'text',
|
|
content: parseResult.content
|
|
};
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
reader.releaseLock();
|
|
}
|
|
}
|
|
} |