// 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) }