|
| 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