summaryrefslogtreecommitdiff
path: root/internal/provider/google/google.go
diff options
context:
space:
mode:
authorClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 22:33:22 +0000
committerClaude Sonnet 5 <noreply@anthropic.com>2026-07-03 22:33:22 +0000
commit1f203a7ac0efad15ec3fc0a4c5b335ad7073a52f (patch)
tree8047b9f63d0b1e80faf3e549b3ad21b9c1f71f1f /internal/provider/google/google.go
parent767ddade57f189827fa956ff8081ca47404a4798 (diff)
feat(provider): add native Google Gemini API adapter (Phase 4)
Adds internal/provider/google, the second native cloud adapter (following internal/provider/anthropic's pattern) on top of Phase 1's provider-neutral tool-use loop, wired to a Docker-sandboxed NativeRunner under agent.type: "google" -- a separate execution path and budget bucket from the existing CLI-subprocess "gemini" ContainerRunner, which is untouched. Wire-format research (the highest-risk part of this adapter): Gemini's multi-turn function-calling shape was resolved by cross-referencing the REST API reference's own generateContent example against the go-genai SDK's struct tags on GitHub -- both agree on functionCall/functionResponse parts keyed by "name" (with an optional "id" for round-tripping ToolCall.ID), with the response fed back inside a "user"-role Content (Gemini has no tool/function role, mirroring Anthropic's lack of one). A separate fetched source (the function-calling guide page) was deliberately discarded as a reference for this shape -- it documents a different, newer "Interactions API" whose call_id/type:"function_result" structure doesn't fit the contents/parts/candidates shape used everywhere else. - internal/provider/google: request/response translation, systemInstruction handling, role mapping (assistant->model, tool-results->user role), per-model-prefix pricing table (2.5 Pro/Flash/Flash-Lite, 2.0, 1.5 tiers) - internal/retry: IsRateLimitError additively extended for RESOURCE_EXHAUSTED - internal/config: RunnersConfig.Google/GoogleEnabled() - internal/cli/serve.go, run.go: runners["google"] construction mirroring the Anthropic wiring exactly (Docker sandbox default) - docs/api-keys-setup.md: Google marked wired-up, budget-bucket/disable/ verify guidance added matching the Anthropic section go build/vet/test -race all pass. No live Gemini API key available in this environment; verified via fake-httptest-server adapter tests (plain text, tool-use round-trip, multi-turn tool-result, rate-limit error matching) plus a Pool/NativeRunner routing test. Live E2E is a follow-up once a key is configured, same as Phase 2's Anthropic adapter. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs
Diffstat (limited to 'internal/provider/google/google.go')
-rw-r--r--internal/provider/google/google.go467
1 files changed, 467 insertions, 0 deletions
diff --git a/internal/provider/google/google.go b/internal/provider/google/google.go
new file mode 100644
index 0000000..45fcfa9
--- /dev/null
+++ b/internal/provider/google/google.go
@@ -0,0 +1,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)
+}