summaryrefslogtreecommitdiff
path: root/internal/provider/google/google.go
blob: 45fcfa9826f6aa55e333adf3688e63a2c7b249c0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
// Package google implements provider.Provider against Google's native Gemini
// API generateContent REST endpoint
// (https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent)
// — the second native cloud adapter added on top of Phase 1's provider-
// neutral tool-use loop (internal/agentloop), following the same pattern as
// internal/provider/anthropic.
//
// Gemini's wire shape is structurally different from both Anthropic's
// (typed content blocks under a flat "content" array) and OpenAI's (flat
// tool_calls array):
//
//   - The system prompt is a top-level "systemInstruction" field (itself a
//     Content{parts:[...]} object), not a role:"system" message and not a
//     bare string like Anthropic's "system".
//   - Message content is "contents[]", each a {"role":..., "parts":[...]}
//     object. Valid role values are only "user" and "model" — there is no
//     "system" or "tool"/"function" role.
//   - A model turn that calls a function is a "model"-role Content whose
//     parts include a {"functionCall": {"name":..., "args": {...}}} part
//     (alongside an optional text part for any accompanying prose).
//   - Feeding a function's result back is done via a {"functionResponse":
//     {"name":..., "response": {...}}} part **inside a "user"-role**
//     Content — Gemini has no "tool"/"function" role, mirroring (for
//     different reasons) Anthropic's lack of one. This was the single
//     highest-risk detail in this adapter; see the wire-shape research note
//     below for how it was confirmed.
//   - Tools are declared as "tools": [{"functionDeclarations": [...]}]
//     (one Tool wrapper holding all declarations), each declaration using
//     "parameters" (not "input_schema" like Anthropic, and not nested under
//     a "function" wrapper like OpenAI's flat tool array).
//
// # Wire-format research note (function-response shape)
//
// The REST API reference (https://ai.google.dev/api/generate-content) is
// the canonical source and documents the full multi-turn shape directly:
// a "model"-role Content with a functionCall part, followed by a
// "user"-role Content with a functionResponse part carrying {"name":...,
// "response": {...}}. This was cross-checked against the Go SDK
// (github.com/googleapis/go-genai)'s type definitions on GitHub, which
// confirm the exact JSON struct tags: Part has `FunctionCall
// *FunctionCall `json:"functionCall,omitempty"`` and `FunctionResponse
// *FunctionResponse `json:"functionResponse,omitempty"``; FunctionCall has
// `Name string `json:"name,omitempty"``, `Args map[string]any
// `json:"args,omitempty"``, and (for parallel-function-calling
// disambiguation) `ID string `json:"id,omitempty"``; FunctionResponse
// mirrors this with `Name`, `Response map[string]any`, and `ID`. Both
// sources agree, so this adapter uses functionCall/functionResponse parts
// keyed primarily by "name" with an optional "id" for round-tripping
// provider.ToolCall.ID.
//
// One fetched source (the function-calling guide,
// https://ai.google.dev/gemini-api/docs/function-calling) was NOT used to
// confirm this shape: as of this research it defaults to documenting a
// newer, structurally different "Interactions API"
// (/v1beta/interactions, snake_case fields like "call_id" and
// "type":"function_result") rather than the classic generateContent API
// this adapter targets. That shape does not fit the
// contents/parts/candidates structure seen everywhere else in the
// generateContent reference and in the SDK, which is exactly the kind of
// red-flag mismatch called out before writing this adapter — it was
// discarded in favor of the REST reference's own generateContent-specific
// multi-turn example and the SDK struct tags, which agree with each other.
//
// The API key is passed as the "?key=" query parameter on the endpoint URL,
// per the REST reference's own curl example for this specific endpoint
// (some other Gemini docs pages show an "x-goog-api-key" header instead,
// but the query-param form is what the generateContent reference itself
// documents, so that's what this adapter uses).
package google

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/thepeterstone/claudomator/internal/provider"
)

// DefaultEndpoint is the Gemini API base, used when Provider.Endpoint is
// empty.
const DefaultEndpoint = "https://generativelanguage.googleapis.com"

// APIVersion is the Gemini REST API version path segment this adapter
// speaks ("v1beta" — the version used throughout the current generateContent
// REST reference; there is no stable "v1" for generateContent as of this
// writing).
const APIVersion = "v1beta"

// DefaultTimeout is used when New is called with a non-positive timeout.
const DefaultTimeout = 120 * time.Second

// Provider implements provider.Provider against the Gemini generateContent
// REST API.
type Provider struct {
	APIKey     string
	Endpoint   string
	HTTPClient *http.Client
}

var _ provider.Provider = (*Provider)(nil)

// New returns a Provider configured with apiKey. endpoint defaults to
// DefaultEndpoint when empty (the native Gemini API base — most callers
// should leave this blank; an override exists only for testing or for
// pointing at a compatible proxy). timeout configures the underlying HTTP
// client and defaults to DefaultTimeout when non-positive.
func New(apiKey, endpoint string, timeout time.Duration) *Provider {
	if endpoint == "" {
		endpoint = DefaultEndpoint
	}
	if timeout <= 0 {
		timeout = DefaultTimeout
	}
	return &Provider{
		APIKey:     apiKey,
		Endpoint:   endpoint,
		HTTPClient: &http.Client{Timeout: timeout},
	}
}

// Name implements provider.Provider.
func (p *Provider) Name() string { return "google" }

// Chat implements provider.Provider by translating req into a Gemini
// generateContent request, performing the HTTP call, and translating the
// response (or error) back into provider-neutral shape.
func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (*provider.ChatResponse, error) {
	if p == nil || p.APIKey == "" {
		return nil, fmt.Errorf("google: no API key configured")
	}
	if req.Model == "" {
		return nil, fmt.Errorf("google: no model configured")
	}

	wireReq := toWireRequest(req)
	body, err := json.Marshal(wireReq)
	if err != nil {
		return nil, fmt.Errorf("google: marshal request: %w", err)
	}

	endpoint := fmt.Sprintf("%s/%s/models/%s:generateContent?key=%s",
		strings.TrimRight(p.Endpoint, "/"), APIVersion, req.Model, url.QueryEscape(p.APIKey))
	httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
	if err != nil {
		return nil, fmt.Errorf("google: build http request: %w", err)
	}
	httpReq.Header.Set("content-type", "application/json")

	client := p.HTTPClient
	if client == nil {
		client = &http.Client{Timeout: DefaultTimeout}
	}
	httpResp, err := client.Do(httpReq)
	if err != nil {
		return nil, fmt.Errorf("google: http: %w", err)
	}
	defer httpResp.Body.Close()

	raw, err := io.ReadAll(httpResp.Body)
	if err != nil {
		return nil, fmt.Errorf("google: read response: %w", err)
	}
	if httpResp.StatusCode >= 400 {
		return nil, errFromStatus(httpResp.StatusCode, raw)
	}

	var wireResp generateContentResponse
	if err := json.Unmarshal(raw, &wireResp); err != nil {
		return nil, fmt.Errorf("google: decode response: %w", err)
	}
	return fromWireResponse(&wireResp, req.Model), nil
}

// --- request/response wire types (Gemini generateContent API) ---

type wireRequest struct {
	Contents          []wireContent         `json:"contents"`
	Tools             []wireTool            `json:"tools,omitempty"`
	SystemInstruction *wireContent          `json:"systemInstruction,omitempty"`
	GenerationConfig  *wireGenerationConfig `json:"generationConfig,omitempty"`
}

// wireContent is Gemini's "Content" object: a role ("user" or "model"; left
// empty for systemInstruction, which needs none) plus an ordered list of
// parts.
type wireContent struct {
	Role  string     `json:"role,omitempty"`
	Parts []wirePart `json:"parts"`
}

// wirePart is a single Gemini "Part". Only one of Text/FunctionCall/
// FunctionResponse is set per part on the way out; on the way in
// (fromWireResponse), only text and functionCall parts are ever produced by
// a non-streaming generateContent response, so those are the only two
// branches handled there.
type wirePart struct {
	Text             string                `json:"text,omitempty"`
	FunctionCall     *wireFunctionCall     `json:"functionCall,omitempty"`
	FunctionResponse *wireFunctionResponse `json:"functionResponse,omitempty"`
}

// wireFunctionCall mirrors the go-genai SDK's FunctionCall struct tags
// (functionCall/name/args/id). Args is a raw JSON object rather than a
// decoded map so provider.ToolCall.ArgsJSON round-trips byte-for-byte
// without an unnecessary unmarshal/remarshal, same trick
// internal/provider/anthropic uses for its "input" field.
type wireFunctionCall struct {
	ID   string          `json:"id,omitempty"`
	Name string          `json:"name"`
	Args json.RawMessage `json:"args,omitempty"`
}

// wireFunctionResponse mirrors the go-genai SDK's FunctionResponse struct
// tags (functionResponse/name/response/id). Unlike Anthropic's tool_result
// block, Gemini imposes no schema on "response" beyond "some JSON object" —
// there is no dedicated is_error field. This adapter's convention (a
// judgment call, not something the wire spec dictates) is
// {"content": "..."} for a successful ToolResult and {"error": "..."} for
// one with IsError set, which keeps the object shape trivially valid JSON
// while still surfacing success/failure to the model.
type wireFunctionResponse struct {
	ID       string         `json:"id,omitempty"`
	Name     string         `json:"name"`
	Response map[string]any `json:"response"`
}

type wireTool struct {
	FunctionDeclarations []wireFunctionDeclaration `json:"functionDeclarations"`
}

type wireFunctionDeclaration struct {
	Name        string         `json:"name"`
	Description string         `json:"description,omitempty"`
	Parameters  map[string]any `json:"parameters,omitempty"`
}

// wireGenerationConfig carries the subset of Gemini's GenerationConfig this
// adapter translates. Unlike Anthropic, Gemini does not require
// maxOutputTokens on every request (it has a model-specific default), so
// (unlike anthropic.defaultMaxTokens) this adapter leaves it unset rather
// than synthesizing a default when ChatRequest.MaxTokens is zero.
type wireGenerationConfig struct {
	Temperature     *float64 `json:"temperature,omitempty"`
	MaxOutputTokens int      `json:"maxOutputTokens,omitempty"`
}

type generateContentResponse struct {
	Candidates    []wireCandidate   `json:"candidates"`
	UsageMetadata wireUsageMetadata `json:"usageMetadata"`
}

type wireCandidate struct {
	Content      wireContent `json:"content"`
	FinishReason string      `json:"finishReason"`
}

type wireUsageMetadata struct {
	PromptTokenCount     int `json:"promptTokenCount"`
	CandidatesTokenCount int `json:"candidatesTokenCount"`
	TotalTokenCount      int `json:"totalTokenCount"`
}

// wireErrorEnvelope is Gemini's error response shape:
//
//	{"error": {"code": 429, "message": "...", "status": "RESOURCE_EXHAUSTED"}}
type wireErrorEnvelope struct {
	Error struct {
		Code    int    `json:"code"`
		Message string `json:"message"`
		Status  string `json:"status"`
	} `json:"error"`
}

// --- translation: provider-neutral -> Gemini wire shape ---

// toWireRequest translates a provider-neutral ChatRequest into the Gemini
// generateContent request shape.
func toWireRequest(req provider.ChatRequest) *wireRequest {
	contents := make([]wireContent, 0, len(req.Messages))
	for _, m := range req.Messages {
		if wc := toWireContent(m); wc != nil {
			contents = append(contents, *wc)
		}
	}

	var tools []wireTool
	if len(req.Tools) > 0 {
		decls := make([]wireFunctionDeclaration, 0, len(req.Tools))
		for _, ts := range req.Tools {
			decls = append(decls, wireFunctionDeclaration{
				Name:        ts.Name,
				Description: ts.Description,
				Parameters:  ts.ParametersJSONSchema,
			})
		}
		tools = []wireTool{{FunctionDeclarations: decls}}
	}

	var sysInstr *wireContent
	if req.System != "" {
		sysInstr = &wireContent{Parts: []wirePart{{Text: req.System}}}
	}

	var genConfig *wireGenerationConfig
	if req.Temperature != nil || req.MaxTokens > 0 {
		genConfig = &wireGenerationConfig{
			Temperature:     req.Temperature,
			MaxOutputTokens: req.MaxTokens,
		}
	}

	return &wireRequest{
		Contents:          contents,
		Tools:             tools,
		SystemInstruction: sysInstr,
		GenerationConfig:  genConfig,
	}
}

// toWireContent translates a single provider-neutral Message into a Gemini
// wire Content, or nil if it carries no parts to send (e.g. an assistant
// turn with neither text nor tool calls — Gemini rejects a Content with an
// empty parts array).
//
// Role mapping: provider-neutral "user" -> Gemini "user"; "assistant" ->
// Gemini "model" (Gemini's own name for the assistant role); "tool" (a
// Message carrying ToolResults, as agentloop.Loop emits after dispatching a
// tool call) has no Gemini role of its own, so — mirroring how Anthropic
// has no "tool" role either — it becomes a **user**-role Content whose
// parts are functionResponse blocks, the standard Gemini pattern for
// feeding a function's result back to the model (see the package doc's
// wire-format research note).
func toWireContent(m provider.Message) *wireContent {
	if len(m.ToolResults) > 0 {
		parts := make([]wirePart, 0, len(m.ToolResults))
		for _, tr := range m.ToolResults {
			respObj := map[string]any{"content": tr.Content}
			if tr.IsError {
				respObj = map[string]any{"error": tr.Content}
			}
			parts = append(parts, wirePart{
				FunctionResponse: &wireFunctionResponse{
					ID:       tr.ToolCallID,
					Name:     tr.Name,
					Response: respObj,
				},
			})
		}
		return &wireContent{Role: "user", Parts: parts}
	}

	role := "user"
	if m.Role == "assistant" {
		role = "model"
	} else if m.Role != "user" {
		// agentloop never emits a Message with role "system" (the system
		// prompt travels in ChatRequest.System instead) or any other role,
		// but fall back to "user" defensively rather than silently dropping
		// content if some future caller does.
		role = "user"
	}

	var parts []wirePart
	if m.Text != "" {
		parts = append(parts, wirePart{Text: m.Text})
	}
	for _, tc := range m.ToolCalls {
		args := json.RawMessage(tc.ArgsJSON)
		if len(args) == 0 || !json.Valid(args) {
			args = json.RawMessage("{}")
		}
		parts = append(parts, wirePart{
			FunctionCall: &wireFunctionCall{
				ID:   tc.ID,
				Name: tc.Name,
				Args: args,
			},
		})
	}
	if len(parts) == 0 {
		return nil
	}
	return &wireContent{Role: role, Parts: parts}
}

// --- translation: Gemini wire shape -> provider-neutral ---

// fromWireResponse translates a Gemini generateContent response into a
// provider-neutral ChatResponse, concatenating any text parts and
// collecting functionCall parts into ToolCalls from the first candidate
// (Gemini can return multiple candidates when candidateCount > 1 is
// requested; this adapter, like the Anthropic one, never requests more than
// the default single candidate, so only candidates[0] is consulted).
// model is the model name that was requested (ChatRequest.Model) — used for
// pricing lookup instead of trying to parse a response-echoed model field,
// since generateContent responses do not reliably include one.
func fromWireResponse(r *generateContentResponse, model string) *provider.ChatResponse {
	var text strings.Builder
	var calls []provider.ToolCall
	var finishReason string

	if len(r.Candidates) > 0 {
		cand := r.Candidates[0]
		finishReason = cand.FinishReason
		for _, part := range cand.Content.Parts {
			switch {
			case part.FunctionCall != nil:
				args := string(part.FunctionCall.Args)
				if args == "" {
					args = "{}"
				}
				calls = append(calls, provider.ToolCall{
					ID:       part.FunctionCall.ID,
					Name:     part.FunctionCall.Name,
					ArgsJSON: args,
				})
			case part.Text != "":
				text.WriteString(part.Text)
			}
		}
	}

	inTok, outTok := r.UsageMetadata.PromptTokenCount, r.UsageMetadata.CandidatesTokenCount
	inPerMTok, outPerMTok := pricingFor(model)
	cost := float64(inTok)/1_000_000*inPerMTok + float64(outTok)/1_000_000*outPerMTok

	return &provider.ChatResponse{
		Text:       text.String(),
		ToolCalls:  calls,
		StopReason: finishReason,
		Usage: provider.Usage{
			InputTokens:  inTok,
			OutputTokens: outTok,
			CostUSD:      cost,
		},
	}
}

// errFromStatus builds an error from a non-2xx Gemini API response,
// embedding the HTTP status code and Gemini's own error "status" (e.g.
// "RESOURCE_EXHAUSTED", "INVALID_ARGUMENT", "PERMISSION_DENIED") in the
// message text so retry.IsRateLimitError can pattern-match quota/rate-limit
// conditions.
func errFromStatus(status int, body []byte) error {
	var env wireErrorEnvelope
	_ = json.Unmarshal(body, &env)

	errStatus := env.Error.Status
	if errStatus == "" {
		errStatus = "UNKNOWN"
	}
	msg := env.Error.Message
	if msg == "" {
		snippet := strings.TrimSpace(string(body))
		if len(snippet) > 500 {
			snippet = snippet[:500] + "..."
		}
		msg = snippet
	}
	return fmt.Errorf("google: http %d %s: %s", status, errStatus, msg)
}