Skip to content

Commit 87922a1

Browse files
Alexey Panfilovclaude
andcommitted
feat: native Gemini provider with Google Search grounding
Replace OpenAI-compat Gemini providers with native generateContent API. This unlocks features unavailable through the compatibility layer: - Google Search grounding (5000 free queries/month) — Gemini automatically searches the web when needed, returns cited answers - inline_data for audio/video/documents (no more input_audio issues) - Native function calling (functionCall/functionResponse) - System instructions as dedicated field (not a message) - Proper multimodal support with MIME types gemini_native.grounding config option controls Google Search; enabled by default in config.yaml. Grounding tool is added alongside function declarations — Gemini decides when to search. Old gemini.go kept for reference but no longer used in main.go. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 19b0cbd commit 87922a1

4 files changed

Lines changed: 357 additions & 11 deletions

File tree

cmd/agent/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,11 @@ func main() {
5656
addProvider("deepseek-r1", p, e)
5757
}
5858
if cfg.Models.GeminiFlashLite.APIKey != "" {
59-
p, e := llm.NewGemini(cfg.Models.GeminiFlashLite)
59+
p, e := llm.NewGeminiNative(cfg.Models.GeminiFlashLite, cfg.GeminiNative.Grounding)
6060
addProvider("gemini-flash-lite", p, e)
6161
}
6262
if cfg.Models.GeminiFlash.APIKey != "" {
63-
p, e := llm.NewGeminiMultimodal(cfg.Models.GeminiFlash)
63+
p, e := llm.NewGeminiNative(cfg.Models.GeminiFlash, cfg.GeminiNative.Grounding)
6464
addProvider("gemini-flash", p, e)
6565
}
6666
if cfg.Models.QwenFlash.APIKey != "" {

config/config.yaml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,14 @@ routing:
8888
tool_filter:
8989
top_k: 20
9090

91+
# Native Gemini API settings (applies to gemini-flash and gemini-flash-lite).
92+
# Grounding gives Gemini access to Google Search — 5000 free queries/month.
93+
gemini_native:
94+
grounding: true
95+
9196
# Ollama web search — gives LLM access to real-time web results.
9297
# Requires an Ollama Cloud API key (ollama.com/settings/keys).
93-
# web_search:
94-
# enabled: true
95-
# api_key: ${OLLAMA_API_KEY}
96-
# base_url: https://ollama.com # default
98+
web_search:
99+
enabled: true
100+
api_key: ${OLLAMA_API_KEY}
101+
base_url: https://ollama.com

internal/config/config.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import (
99
)
1010

1111
type Config struct {
12-
Telegram TelegramConfig `yaml:"telegram"`
13-
Models ModelsConfig `yaml:"models"`
14-
Routing RoutingConfig `yaml:"routing"`
15-
ToolFilter ToolFilterConfig `yaml:"tool_filter"`
16-
WebSearch WebSearchConfig `yaml:"web_search"`
12+
Telegram TelegramConfig `yaml:"telegram"`
13+
Models ModelsConfig `yaml:"models"`
14+
Routing RoutingConfig `yaml:"routing"`
15+
ToolFilter ToolFilterConfig `yaml:"tool_filter"`
16+
WebSearch WebSearchConfig `yaml:"web_search"`
17+
GeminiNative GeminiNativeConfig `yaml:"gemini_native"`
1718
}
1819

1920
type WebSearchConfig struct {
@@ -59,6 +60,10 @@ type ModelsConfig struct {
5960
Ollama ModelConfig `yaml:"ollama"`
6061
}
6162

63+
type GeminiNativeConfig struct {
64+
Grounding bool `yaml:"grounding"` // enable Google Search grounding
65+
}
66+
6267
type RoutingConfig struct {
6368
Default string `yaml:"default"`
6469
Fallback string `yaml:"fallback"`

internal/llm/gemini_native.go

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
package llm
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"strings"
11+
"time"
12+
13+
"telegram-agent/internal/config"
14+
)
15+
16+
const (
17+
geminiAPIBase = "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent"
18+
geminiDefaultTimeout = 5 * time.Minute
19+
)
20+
21+
var geminiHTTPClient = &http.Client{Timeout: geminiDefaultTimeout}
22+
23+
// GeminiNativeProvider uses the native Gemini generateContent API.
24+
// Supports inline audio/video/documents, Google Search grounding, and thinking mode.
25+
type GeminiNativeProvider struct {
26+
model string
27+
apiKey string
28+
maxTokens int
29+
provName string
30+
grounding bool // enable Google Search grounding
31+
}
32+
33+
// NewGeminiNative creates a provider using the native Gemini API.
34+
func NewGeminiNative(cfg config.ModelConfig, grounding bool) (*GeminiNativeProvider, error) {
35+
if cfg.APIKey == "" {
36+
return nil, fmt.Errorf("gemini-native: api_key is required")
37+
}
38+
if cfg.Model == "" {
39+
return nil, fmt.Errorf("gemini-native: model is required")
40+
}
41+
maxTokens := cfg.MaxTokens
42+
if maxTokens == 0 {
43+
maxTokens = 4096
44+
}
45+
return &GeminiNativeProvider{
46+
model: cfg.Model,
47+
apiKey: cfg.APIKey,
48+
maxTokens: maxTokens,
49+
provName: "gemini-native",
50+
grounding: grounding,
51+
}, nil
52+
}
53+
54+
func (p *GeminiNativeProvider) Name() string {
55+
return p.provName + "/" + p.model
56+
}
57+
58+
// --- Native Gemini request/response types ---
59+
60+
type geminiRequest struct {
61+
Contents []geminiContent `json:"contents"`
62+
SystemInstruction *geminiContent `json:"system_instruction,omitempty"`
63+
Tools []geminiToolDecl `json:"tools,omitempty"`
64+
GenerationConfig *geminiGenerationConfig `json:"generation_config,omitempty"`
65+
}
66+
67+
type geminiContent struct {
68+
Role string `json:"role,omitempty"`
69+
Parts []geminiPart `json:"parts"`
70+
}
71+
72+
// geminiPart uses pointers/omitempty so only one field is serialised per part.
73+
type geminiPart struct {
74+
Text string `json:"text,omitempty"`
75+
InlineData *geminiInlineData `json:"inline_data,omitempty"`
76+
FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
77+
FunctionResponse *geminiFunctionResp `json:"functionResponse,omitempty"`
78+
}
79+
80+
type geminiInlineData struct {
81+
MIMEType string `json:"mime_type"`
82+
Data string `json:"data"` // base64
83+
}
84+
85+
type geminiFunctionCall struct {
86+
Name string `json:"name"`
87+
Args any `json:"args"`
88+
}
89+
90+
type geminiFunctionResp struct {
91+
Name string `json:"name"`
92+
Response any `json:"response"`
93+
}
94+
95+
type geminiToolDecl struct {
96+
FunctionDeclarations []geminiFuncDecl `json:"functionDeclarations,omitempty"`
97+
GoogleSearch *struct{} `json:"google_search,omitempty"`
98+
}
99+
100+
type geminiFuncDecl struct {
101+
Name string `json:"name"`
102+
Description string `json:"description,omitempty"`
103+
Parameters any `json:"parameters,omitempty"`
104+
}
105+
106+
type geminiGenerationConfig struct {
107+
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
108+
}
109+
110+
type geminiResponse struct {
111+
Candidates []struct {
112+
Content struct {
113+
Parts []geminiPart `json:"parts"`
114+
} `json:"content"`
115+
} `json:"candidates"`
116+
Error *struct {
117+
Message string `json:"message"`
118+
Code int `json:"code"`
119+
} `json:"error"`
120+
}
121+
122+
func (p *GeminiNativeProvider) Chat(ctx context.Context, messages []Message, systemPrompt string, tools []Tool) (Response, error) {
123+
req := geminiRequest{
124+
Contents: p.buildContents(messages),
125+
GenerationConfig: &geminiGenerationConfig{
126+
MaxOutputTokens: p.maxTokens,
127+
},
128+
}
129+
130+
if systemPrompt != "" {
131+
req.SystemInstruction = &geminiContent{
132+
Parts: []geminiPart{{Text: systemPrompt}},
133+
}
134+
}
135+
136+
// Build tools: function declarations + optional grounding.
137+
var toolDecls []geminiToolDecl
138+
if len(tools) > 0 {
139+
toolDecls = append(toolDecls, geminiToolDecl{
140+
FunctionDeclarations: buildGeminiFuncDecls(tools),
141+
})
142+
}
143+
if p.grounding {
144+
toolDecls = append(toolDecls, geminiToolDecl{
145+
GoogleSearch: &struct{}{},
146+
})
147+
}
148+
if len(toolDecls) > 0 {
149+
req.Tools = toolDecls
150+
}
151+
152+
body, err := json.Marshal(req)
153+
if err != nil {
154+
return Response{}, fmt.Errorf("%s: marshal: %w", p.provName, err)
155+
}
156+
157+
url := fmt.Sprintf(geminiAPIBase, p.model)
158+
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
159+
if err != nil {
160+
return Response{}, fmt.Errorf("%s: request: %w", p.provName, err)
161+
}
162+
httpReq.Header.Set("Content-Type", "application/json")
163+
httpReq.Header.Set("x-goog-api-key", p.apiKey)
164+
165+
httpResp, err := geminiHTTPClient.Do(httpReq)
166+
if err != nil {
167+
return Response{}, fmt.Errorf("%s: %w", p.provName, err)
168+
}
169+
defer httpResp.Body.Close()
170+
171+
respBody, err := io.ReadAll(httpResp.Body)
172+
if err != nil {
173+
return Response{}, fmt.Errorf("%s: read: %w", p.provName, err)
174+
}
175+
176+
var gemResp geminiResponse
177+
if err := json.Unmarshal(respBody, &gemResp); err != nil {
178+
return Response{}, fmt.Errorf("%s: parse: %w", p.provName, err)
179+
}
180+
if gemResp.Error != nil {
181+
return Response{}, &APIError{StatusCode: gemResp.Error.Code, Message: gemResp.Error.Message}
182+
}
183+
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
184+
return Response{}, &APIError{StatusCode: httpResp.StatusCode, Message: string(respBody)}
185+
}
186+
if len(gemResp.Candidates) == 0 {
187+
return Response{}, fmt.Errorf("%s: empty response", p.provName)
188+
}
189+
190+
// Parse response parts: text and/or function calls.
191+
var result Response
192+
for i, part := range gemResp.Candidates[0].Content.Parts {
193+
if part.Text != "" {
194+
if result.Content != "" {
195+
result.Content += "\n"
196+
}
197+
result.Content += part.Text
198+
}
199+
if part.FunctionCall != nil {
200+
argsJSON, err := json.Marshal(part.FunctionCall.Args)
201+
if err != nil {
202+
argsJSON = []byte("{}")
203+
}
204+
result.ToolCalls = append(result.ToolCalls, ToolCall{
205+
ID: fmt.Sprintf("call_%d", i),
206+
Name: part.FunctionCall.Name,
207+
Arguments: string(argsJSON),
208+
})
209+
}
210+
}
211+
return result, nil
212+
}
213+
214+
// buildContents converts internal messages to native Gemini format.
215+
func (p *GeminiNativeProvider) buildContents(messages []Message) []geminiContent {
216+
// Build a map from tool call ID → function name for functionResponse.
217+
toolNames := make(map[string]string)
218+
for _, m := range messages {
219+
for _, tc := range m.ToolCalls {
220+
toolNames[tc.ID] = tc.Name
221+
}
222+
}
223+
224+
var contents []geminiContent
225+
226+
for _, m := range messages {
227+
role := m.Role
228+
switch role {
229+
case "assistant":
230+
role = "model"
231+
case "system":
232+
continue
233+
case "tool":
234+
funcName := toolNames[m.ToolCallID]
235+
if funcName == "" {
236+
funcName = m.ToolCallID // fallback
237+
}
238+
contents = append(contents, geminiContent{
239+
Role: "user",
240+
Parts: []geminiPart{{
241+
FunctionResponse: &geminiFunctionResp{
242+
Name: funcName,
243+
Response: map[string]any{"result": m.Content},
244+
},
245+
}},
246+
})
247+
continue
248+
}
249+
250+
var parts []geminiPart
251+
252+
// Handle multimodal parts.
253+
if len(m.Parts) > 0 {
254+
for _, mp := range m.Parts {
255+
switch mp.Type {
256+
case "text":
257+
parts = append(parts, geminiPart{Text: mp.Text})
258+
case "image_url":
259+
if mp.ImageURL != nil {
260+
mime, data := parseDataURI(mp.ImageURL.URL)
261+
if data != "" {
262+
parts = append(parts, geminiPart{
263+
InlineData: &geminiInlineData{MIMEType: mime, Data: data},
264+
})
265+
}
266+
}
267+
case "input_audio":
268+
if mp.InputAudio != nil {
269+
parts = append(parts, geminiPart{
270+
InlineData: &geminiInlineData{
271+
MIMEType: mp.InputAudio.Format, // we pass MIME type here
272+
Data: mp.InputAudio.Data,
273+
},
274+
})
275+
}
276+
}
277+
}
278+
} else if role == "model" && len(m.ToolCalls) > 0 {
279+
// Assistant message with tool calls → functionCall parts.
280+
for _, tc := range m.ToolCalls {
281+
var args any
282+
json.Unmarshal([]byte(tc.Arguments), &args) //nolint:errcheck
283+
parts = append(parts, geminiPart{
284+
FunctionCall: &geminiFunctionCall{Name: tc.Name, Args: args},
285+
})
286+
}
287+
if m.Content != "" {
288+
parts = append(parts, geminiPart{Text: m.Content})
289+
}
290+
} else {
291+
parts = append(parts, geminiPart{Text: m.Content})
292+
}
293+
294+
if len(parts) > 0 {
295+
contents = append(contents, geminiContent{Role: role, Parts: parts})
296+
}
297+
}
298+
return contents
299+
}
300+
301+
// parseDataURI extracts MIME type and base64 data from "data:mime;base64,DATA".
302+
func parseDataURI(uri string) (mime, data string) {
303+
// data:image/jpeg;base64,/9j/4AAQ...
304+
if !strings.HasPrefix(uri, "data:") {
305+
return "image/jpeg", uri // assume raw base64
306+
}
307+
rest := uri[5:] // after "data:"
308+
semicolon := strings.Index(rest, ";")
309+
if semicolon < 0 {
310+
return "image/jpeg", uri
311+
}
312+
mime = rest[:semicolon]
313+
comma := strings.Index(rest, ",")
314+
if comma < 0 {
315+
return mime, ""
316+
}
317+
return mime, rest[comma+1:]
318+
}
319+
320+
func buildGeminiFuncDecls(tools []Tool) []geminiFuncDecl {
321+
decls := make([]geminiFuncDecl, 0, len(tools))
322+
for _, t := range tools {
323+
var params any
324+
if len(t.InputSchema) > 0 {
325+
params = json.RawMessage(t.InputSchema)
326+
} else {
327+
params = map[string]any{"type": "object", "properties": map[string]any{}}
328+
}
329+
decls = append(decls, geminiFuncDecl{
330+
Name: t.Name,
331+
Description: t.Description,
332+
Parameters: params,
333+
})
334+
}
335+
return decls
336+
}

0 commit comments

Comments
 (0)