Skip to content

Commit cf55256

Browse files
committed
feat: add Google Gemini provider support (v2.8.0)
1 parent f46552d commit cf55256

5 files changed

Lines changed: 287 additions & 6 deletions

File tree

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "opencode-mem",
3-
"version": "2.7.7",
3+
"version": "2.8.0",
44
"description": "OpenCode plugin that gives coding agents persistent memory using local vector database",
55
"type": "module",
66
"main": "dist/plugin.js",

src/services/ai/ai-provider-factory.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { BaseAIProvider, type ProviderConfig } from "./providers/base-provider.j
22
import { OpenAIChatCompletionProvider } from "./providers/openai-chat-completion.js";
33
import { OpenAIResponsesProvider } from "./providers/openai-responses.js";
44
import { AnthropicMessagesProvider } from "./providers/anthropic-messages.js";
5+
import { GoogleGeminiProvider } from "./providers/google-gemini.js";
56
import { aiSessionManager } from "./session/ai-session-manager.js";
67
import type { AIProviderType } from "./session/session-types.js";
78

@@ -17,13 +18,16 @@ export class AIProviderFactory {
1718
case "anthropic":
1819
return new AnthropicMessagesProvider(config, aiSessionManager);
1920

21+
case "google-gemini":
22+
return new GoogleGeminiProvider(config, aiSessionManager);
23+
2024
default:
2125
throw new Error(`Unknown provider type: ${providerType}`);
2226
}
2327
}
2428

2529
static getSupportedProviders(): AIProviderType[] {
26-
return ["openai-chat", "openai-responses", "anthropic"];
30+
return ["openai-chat", "openai-responses", "anthropic", "google-gemini"];
2731
}
2832

2933
static cleanupExpiredSessions(): number {
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { BaseAIProvider, type ToolCallResult } from "./base-provider.js";
2+
import { AISessionManager } from "../session/ai-session-manager.js";
3+
import type { ChatCompletionTool } from "../tools/tool-schema.js";
4+
import { log } from "../../logger.js";
5+
import { UserProfileValidator } from "../validators/user-profile-validator.js";
6+
7+
/**
8+
* Google Gemini Provider
9+
* Supports Google's Gemini models (e.g. gemini-1.5-flash) via Google AI Studio API.
10+
*/
11+
export class GoogleGeminiProvider extends BaseAIProvider {
12+
private aiSessionManager: AISessionManager;
13+
14+
constructor(config: any, aiSessionManager: AISessionManager) {
15+
super(config);
16+
this.aiSessionManager = aiSessionManager;
17+
}
18+
19+
getProviderName(): string {
20+
return "google-gemini";
21+
}
22+
23+
supportsSession(): boolean {
24+
return true;
25+
}
26+
27+
private addToolResponse(
28+
sessionId: string,
29+
messages: any[],
30+
toolCallId: string,
31+
content: string
32+
): void {
33+
const sequence = this.aiSessionManager.getLastSequence(sessionId) + 1;
34+
this.aiSessionManager.addMessage({
35+
aiSessionId: sessionId,
36+
sequence,
37+
role: "tool",
38+
content,
39+
toolCallId,
40+
});
41+
// Gemini tool response format
42+
messages.push({
43+
role: "function",
44+
parts: [
45+
{
46+
functionResponse: {
47+
name: toolCallId.split(":")[0], // Gemini expects the name of the function
48+
response: JSON.parse(content),
49+
},
50+
},
51+
],
52+
});
53+
}
54+
55+
async executeToolCall(
56+
systemPrompt: string,
57+
userPrompt: string,
58+
toolSchema: ChatCompletionTool,
59+
sessionId: string
60+
): Promise<ToolCallResult> {
61+
let session = this.aiSessionManager.getSession(sessionId, "google-gemini");
62+
63+
if (!session) {
64+
session = this.aiSessionManager.createSession({
65+
provider: "google-gemini",
66+
sessionId,
67+
});
68+
}
69+
70+
const existingMessages = this.aiSessionManager.getMessages(session.id);
71+
const contents: any[] = [];
72+
73+
// System instruction is separate in Gemini API
74+
const geminiSystemInstruction = {
75+
parts: [{ text: systemPrompt }],
76+
};
77+
78+
// Convert existing messages to Gemini format
79+
for (const msg of existingMessages) {
80+
if (msg.role === "system") continue; // Skip system as it's passed separately
81+
82+
const role = msg.role === "assistant" ? "model" : "user";
83+
const parts: any[] = [];
84+
85+
if (msg.content) {
86+
parts.push({ text: msg.content });
87+
}
88+
89+
if (msg.toolCalls) {
90+
for (const tc of msg.toolCalls) {
91+
parts.push({
92+
functionCall: {
93+
name: tc.function.name,
94+
args: JSON.parse(tc.function.arguments),
95+
},
96+
});
97+
}
98+
}
99+
100+
if (msg.role === "tool") {
101+
contents.push({
102+
role: "function",
103+
parts: [
104+
{
105+
functionResponse: {
106+
name: (msg.toolCallId || "").split(":")[0],
107+
response: JSON.parse(msg.content),
108+
},
109+
},
110+
],
111+
});
112+
continue;
113+
}
114+
115+
contents.push({ role, parts });
116+
}
117+
118+
if (contents.length === 0 || contents[contents.length - 1].role !== "user") {
119+
const userSequence = this.aiSessionManager.getLastSequence(session.id) + 1;
120+
this.aiSessionManager.addMessage({
121+
aiSessionId: session.id,
122+
sequence: userSequence,
123+
role: "user",
124+
content: userPrompt,
125+
});
126+
contents.push({ role: "user", parts: [{ text: userPrompt }] });
127+
}
128+
129+
let iterations = 0;
130+
const maxIterations = this.config.maxIterations ?? 5;
131+
const iterationTimeout = this.config.iterationTimeout ?? 30000;
132+
133+
// Gemini API expects the tool name as a function declaration
134+
const tools = [
135+
{
136+
functionDeclarations: [
137+
{
138+
name: toolSchema.function.name,
139+
description: toolSchema.function.description,
140+
parameters: toolSchema.function.parameters,
141+
},
142+
],
143+
},
144+
];
145+
146+
while (iterations < maxIterations) {
147+
iterations++;
148+
149+
const controller = new AbortController();
150+
const timeout = setTimeout(() => controller.abort(), iterationTimeout);
151+
152+
try {
153+
const baseUrl = this.config.apiUrl || "https://generativelanguage.googleapis.com/v1beta";
154+
const url = `${baseUrl}/models/${this.config.model}:generateContent?key=${this.config.apiKey}`;
155+
156+
const requestBody: any = {
157+
contents,
158+
systemInstruction: geminiSystemInstruction,
159+
tools,
160+
toolConfig: {
161+
functionCallingConfig: {
162+
mode: "ANY", // Force function calling
163+
allowedFunctionNames: [toolSchema.function.name],
164+
},
165+
},
166+
generationConfig: {
167+
temperature: this.config.memoryTemperature ?? 0.3,
168+
},
169+
};
170+
171+
const response = await fetch(url, {
172+
method: "POST",
173+
headers: { "Content-Type": "application/json" },
174+
body: JSON.stringify(requestBody),
175+
signal: controller.signal,
176+
});
177+
178+
clearTimeout(timeout);
179+
180+
if (!response.ok) {
181+
const errorText = await response.text().catch(() => response.statusText);
182+
log("Gemini API error", {
183+
status: response.status,
184+
error: errorText,
185+
iteration: iterations,
186+
});
187+
return {
188+
success: false,
189+
error: `Gemini API error: ${response.status} - ${errorText}`,
190+
iterations,
191+
};
192+
}
193+
194+
const data = (await response.json()) as any;
195+
const candidate = data.candidates?.[0];
196+
197+
if (!candidate || !candidate.content) {
198+
return { success: false, error: "Invalid Gemini API response format", iterations };
199+
}
200+
201+
const modelMsg = candidate.content;
202+
const assistantSequence = this.aiSessionManager.getLastSequence(session.id) + 1;
203+
204+
// Map Gemini response back to our internal message format
205+
const assistantMsg: any = {
206+
aiSessionId: session.id,
207+
sequence: assistantSequence,
208+
role: "assistant",
209+
content: "",
210+
toolCalls: [],
211+
};
212+
213+
for (const part of modelMsg.parts) {
214+
if (part.text) assistantMsg.content += part.text;
215+
if (part.functionCall) {
216+
assistantMsg.toolCalls.push({
217+
id: `${part.functionCall.name}:${Date.now()}`,
218+
type: "function",
219+
function: {
220+
name: part.functionCall.name,
221+
arguments: JSON.stringify(part.functionCall.args),
222+
},
223+
});
224+
}
225+
}
226+
227+
this.aiSessionManager.addMessage(assistantMsg);
228+
contents.push(modelMsg);
229+
230+
if (assistantMsg.toolCalls.length > 0) {
231+
for (const toolCall of assistantMsg.toolCalls) {
232+
if (toolCall.function.name === toolSchema.function.name) {
233+
try {
234+
const parsed = JSON.parse(toolCall.function.arguments);
235+
const result = UserProfileValidator.validate(parsed);
236+
if (!result.valid) throw new Error(result.errors.join(", "));
237+
238+
this.addToolResponse(
239+
session.id,
240+
contents,
241+
toolCall.id,
242+
JSON.stringify({ success: true })
243+
);
244+
return { success: true, data: result.data, iterations };
245+
} catch (validationError) {
246+
const errorMessage = `Validation failed: ${String(validationError)}`;
247+
this.addToolResponse(
248+
session.id,
249+
contents,
250+
toolCall.id,
251+
JSON.stringify({ success: false, error: errorMessage })
252+
);
253+
return { success: false, error: errorMessage, iterations };
254+
}
255+
}
256+
}
257+
}
258+
259+
// Retry if no tool call was made
260+
const retryPrompt = "Please use the save_memories tool as instructed.";
261+
const retrySequence = this.aiSessionManager.getLastSequence(session.id) + 1;
262+
this.aiSessionManager.addMessage({
263+
aiSessionId: session.id,
264+
sequence: retrySequence,
265+
role: "user",
266+
content: retryPrompt,
267+
});
268+
contents.push({ role: "user", parts: [{ text: retryPrompt }] });
269+
} catch (error) {
270+
clearTimeout(timeout);
271+
return { success: false, error: String(error), iterations };
272+
}
273+
}
274+
275+
return { success: false, error: `Max iterations (${maxIterations}) reached`, iterations };
276+
}
277+
}

src/services/ai/session/session-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export type AIProviderType = "openai-chat" | "openai-responses" | "anthropic";
1+
export type AIProviderType = "openai-chat" | "openai-responses" | "anthropic" | "google-gemini";
22

33
export interface AIMessage {
44
id?: number;

0 commit comments

Comments
 (0)