package main import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" ) // provider_gemini.go is the Gemini backend. Two faces: // // - geminiClient: a thin LLMClient over the OpenAI-compatible endpoint, used for the // cheap trivial route and the Layer-1 router classifier. Same wire format as Grok, // so it reuses the shared transport (httpllm.go). // - groundedSearch: a SEPARATE call against the NATIVE v1beta generateContent endpoint // with the google_search tool. Grounding does NOT work on the OpenAI-compat layer — // it is silently ignored THERE (F-EXT-3, an endpoint limitation, NOT a model-version // one: the google_search tool is supported by current models including // gemini-2.5-flash-lite per ai.google.dev). So the web layer that wants Gemini // grounding must use this native path and VERIFY citations came back, else degrade. // maxGroundingRespBytes caps the native grounding response read so a hostile or // malfunctioning endpoint cannot drive unbounded memory growth via io.ReadAll. const maxGroundingRespBytes = 1 << 20 // 1 MiB type geminiClient struct { http *openAIClient nativeBase string // …/v1beta — derived from the OpenAI-compat base by dropping /openai key string model string httpc *http.Client log *slog.Logger } // NewGeminiClient builds the Gemini backend. base is the OpenAI-compatible endpoint // (…/v1beta/openai); the native grounding endpoint is derived from it. Returns the // concrete type (not just LLMClient) because the web layer needs groundedSearch too. func NewGeminiClient(base, key, model string, logger *slog.Logger) *geminiClient { return &geminiClient{ http: newOpenAIClient("gemini", base, key, nil, logger), nativeBase: strings.TrimSuffix(base, "/openai"), key: key, model: model, httpc: &http.Client{ // Refuse redirects: the native endpoint never legitimately redirects, // and following one to another host would be an exfil path. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, log: logger, } } // Complete answers via the OpenAI-compatible endpoint (trivial route + classifier). func (c *geminiClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) { msgs := make([]openAIMessage, len(req.Messages)) for i, m := range req.Messages { msgs[i] = openAIMessage{Role: m.Role, Content: m.Content} } var respFormat any if req.JSONOnly { respFormat = map[string]string{"type": "json_object"} } resp, err := c.http.complete(ctx, openAIRequest{ Model: req.Model, Messages: msgs, MaxTokens: req.MaxTokens, Temperature: req.Temperature, Stream: false, ResponseFormat: respFormat, }, nil) if err != nil { return nil, err } return &LLMResponse{ Text: resp.Text(), Usage: Usage{ PromptTokens: resp.Usage.PromptTokens, CachedTokens: resp.Usage.PromptTokensDetails.CachedTokens, CompletionTokens: resp.Usage.CompletionTokens, }, ProviderRequestID: resp.ID, }, nil } // --- native v1beta grounded search (google_search tool) --------------------------- type geminiGroundResult struct { Digest string Citations []string // redirect URIs — the verify-gate + citation_count Sources []WebSource // the same chunks with their publisher-domain titles (web.title) Usage Usage } // native generateContent wire types (only the fields we read/write). type geminiNativeRequest struct { Contents []geminiContent `json:"contents"` Tools []geminiTool `json:"tools"` GenerationConfig *geminiGenConfig `json:"generationConfig,omitempty"` } type geminiGenConfig struct { MaxOutputTokens int `json:"maxOutputTokens,omitempty"` } type geminiContent struct { Role string `json:"role,omitempty"` Parts []geminiPart `json:"parts"` } type geminiPart struct { Text string `json:"text"` } type geminiTool struct { // google_search is the current grounding tool (all current models, incl. the 2.5 // family; legacy models used google_search_retrieval). The empty object enables it. GoogleSearch struct{} `json:"google_search"` } type geminiNativeResponse struct { Candidates []struct { Content struct { Parts []geminiPart `json:"parts"` } `json:"content"` GroundingMetadata struct { GroundingChunks []struct { Web struct { URI string `json:"uri"` Title string `json:"title"` } `json:"web"` } `json:"groundingChunks"` } `json:"groundingMetadata"` } `json:"candidates"` UsageMetadata struct { PromptTokenCount int `json:"promptTokenCount"` CandidatesTokenCount int `json:"candidatesTokenCount"` CachedContentTokenCount int `json:"cachedContentTokenCount"` // ThoughtsTokenCount is ADDITIVE to candidatesTokenCount (Google documents // thinking tokens separately) — same convention as Usage.ReasoningTokens. // 0 on flash-lite (thinking off by default), but a model swap must not // silently re-open the unbilled-thinking hole. ThoughtsTokenCount int `json:"thoughtsTokenCount"` } `json:"usageMetadata"` } // groundedDigestMaxTokens bounds the fetched digest. Big enough for a dense // multi-fact summary, small enough that the synthesis prompt stays cheap. const groundedDigestMaxTokens = 1024 // groundedSearch runs one grounded generateContent against the native endpoint and // returns the model's grounded answer plus the source URLs. It REQUIRES citations: // if groundingMetadata has no chunks the request was not actually grounded (the // silent-ignore failure mode, F-EXT-3), so it errors and the caller degrades rather // than passing off ungrounded — possibly stale — text as fresh. // // The query is wrapped in a fetch instruction instead of being sent bare: a bare // query leaves digest depth, dating, and language to Gemini's chat defaults, and the // model has no idea what "today" is. The digest is the route's most important // intermediate artifact and its tokens are ~free at flash-lite prices, so instruct it. func (c *geminiClient) groundedSearch(ctx context.Context, query string) (geminiGroundResult, error) { prompt := dateNote(time.Now()) + webFetchInstruction + query body, err := json.Marshal(geminiNativeRequest{ Contents: []geminiContent{{Role: "user", Parts: []geminiPart{{Text: prompt}}}}, Tools: []geminiTool{{}}, GenerationConfig: &geminiGenConfig{MaxOutputTokens: groundedDigestMaxTokens}, }) if err != nil { return geminiGroundResult{}, err } // The key goes in the x-goog-api-key header, NOT the query string: a // transport-level failure (DNS/timeout/TLS) returns a *url.Error whose // Error() embeds the request URL verbatim, and the cascade logs that error // at WARN — a `?key=` would leak the secret into the log backend. endpoint := fmt.Sprintf("%s/models/%s:generateContent", c.nativeBase, url.PathEscape(c.model)) reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) // web/grounding budget (§8.2.2) defer cancel() req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return geminiGroundResult{}, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-goog-api-key", c.key) resp, err := c.httpc.Do(req) if err != nil { return geminiGroundResult{}, err } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, maxGroundingRespBytes)) logLLMExchange(ctx, c.log, "gemini_grounding", body, resp.StatusCode, data) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return geminiGroundResult{}, fmt.Errorf("gemini grounding http %d: %s", resp.StatusCode, snippet(data)) } var out geminiNativeResponse if err := json.Unmarshal(data, &out); err != nil { return geminiGroundResult{}, fmt.Errorf("gemini grounding decode: %w", err) } if len(out.Candidates) == 0 { return geminiGroundResult{}, fmt.Errorf("gemini grounding: no candidates") } var sb strings.Builder for _, p := range out.Candidates[0].Content.Parts { sb.WriteString(p.Text) } var citations []string var sources []WebSource for _, ch := range out.Candidates[0].GroundingMetadata.GroundingChunks { if ch.Web.URI != "" { citations = append(citations, ch.Web.URI) // web.uri is the grounding-api-redirect (NOT the publisher URL — and Gemini's // terms forbid resolving it server-side); web.title is the publisher domain // ("rbc.ru"). Keep both: the user clicks the redirect to reach the real article. sources = append(sources, WebSource{Title: ch.Web.Title, URL: ch.Web.URI}) } } // The verify-gate: no citations ⇒ not actually grounded ⇒ degrade. if len(citations) == 0 { return geminiGroundResult{}, fmt.Errorf("gemini grounding: no citations (ungrounded — degrade)") } return geminiGroundResult{ Digest: strings.TrimSpace(sb.String()), Citations: citations, Sources: sources, Usage: Usage{ PromptTokens: out.UsageMetadata.PromptTokenCount, CachedTokens: out.UsageMetadata.CachedContentTokenCount, CompletionTokens: out.UsageMetadata.CandidatesTokenCount, ReasoningTokens: out.UsageMetadata.ThoughtsTokenCount, }, }, nil }