From 1f203a7ac0efad15ec3fc0a4c5b335ad7073a52f Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Fri, 3 Jul 2026 22:33:22 +0000 Subject: 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 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- internal/provider/google/google.go | 467 +++++++++++++++++++++++++++++++++++++ 1 file changed, 467 insertions(+) create mode 100644 internal/provider/google/google.go (limited to 'internal/provider/google/google.go') 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) +} -- cgit v1.2.3