blob: 34b56c7f244b96d777bae8a70e1d3f0e159bae81 [file]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package gemini implements access to Google's Gemini model.
//
// [Client] implements [llm.Embedder] and [llm.GenerateContent]. Use [NewClient] to connect.
package gemini
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"slices"
"strings"
"golang.org/x/oscar/internal/httprr"
"golang.org/x/oscar/internal/llm"
"golang.org/x/oscar/internal/secret"
"google.golang.org/genai"
)
// Scrub is a request scrubber for use with [rsc.io/httprr].
func Scrub(req *http.Request) error {
req.Header.Del("X-Goog-Api-Key") // delete API key
req.Header.Del("X-Goog-Api-Client") // contains version numbers
req.Header.Set("User-Agent", "gemini httprecord") // contains same version numbers
if ctype := req.Header.Get("Content-Type"); ctype == "application/json" || strings.HasPrefix(ctype, "application/json;") {
// Canonicalize JSON body.
// google.golang.org/protobuf/internal/encoding.json
// goes out of its way to randomize the JSON encodings
// of protobuf messages by adding or not adding spaces
// after commas. Derandomize by compacting the JSON.
b := req.Body.(*httprr.Body)
var buf bytes.Buffer
if err := json.Compact(&buf, b.Data); err == nil {
b.Data = buf.Bytes()
}
}
return nil
}
// A Client represents a connection to Gemini.
type Client struct {
slog *slog.Logger
genai *genai.Client
generativeModel string
embeddingModel string
dim int32
temperature float32 // negative means use default
}
const (
DefaultEmbeddingModel = "gemini-embedding-001"
DefaultGenerativeModel = "gemini-2.5-pro"
)
// forceEmbedDim is the hard-coded forced dimensionality of
// vectors returned by this package. The original Gemini models
// used 768, and so our databases are configured for 768.
// Newer Gemini models produce larger embeddings, up to 3072,
// but truncating to 768 is still plenty good enough, because the
// vectors are arranged so that the most important information is
// in the leading entries. We just need to renormalize after truncation.
// See https://ai.google.dev/gemini-api/docs/embeddings#control-embedding-size
const forceEmbedDim = 768
// NewClient returns a connection to Gemini, using the given logger and HTTP client.
// It expects to find a secret of the form "AIza..." or "user:AIza..." in sdb
// under the name "ai.google.dev".
// The embeddingModel is the model name to use for embedding, such as text-embedding-004,
// and the generativeModel is the model name to use for generation, such as gemini-1.5-pro.
func NewClient(ctx context.Context, lg *slog.Logger, sdb secret.DB, hc *http.Client, embeddingModel, generativeModel string) (*Client, error) {
key, ok := sdb.Get("ai.google.dev")
if !ok {
return nil, fmt.Errorf("missing api key for ai.google.dev")
}
// If key is from .netrc, ignore user name.
if _, pass, ok := strings.Cut(key, ":"); ok {
key = pass
}
ai, err := genai.NewClient(ctx, &genai.ClientConfig{
APIKey: key,
HTTPClient: hc,
})
if err != nil {
return nil, err
}
return &Client{
slog: lg,
genai: ai,
embeddingModel: embeddingModel,
dim: forceEmbedDim,
generativeModel: generativeModel,
temperature: -1,
}, nil
}
const maxBatch = 100 // empirical limit
var _ llm.Embedder = (*Client)(nil)
// EmbeddingModel returns the name of the embedding model.
// It is of the form name/dim where dim is the dimensionality.
func (c *Client) EmbeddingModel() string {
return fmt.Sprintf("%s/%d", c.embeddingModel, c.dim)
}
// EmbedDocs returns the vector embeddings for the docs,
// implementing [llm.Embedder].
func (c *Client) EmbedDocs(ctx context.Context, docs []llm.EmbedDoc) ([]llm.Vector, error) {
config := &genai.EmbedContentConfig{
OutputDimensionality: &c.dim,
}
var vecs []llm.Vector
for docs := range slices.Chunk(docs, maxBatch) {
var contents []*genai.Content
for _, d := range docs {
contents = append(contents, genai.Text("Title: "+d.Title+"\n\n"+d.Text)...)
}
resp, err := c.genai.Models.EmbedContent(ctx, c.embeddingModel, contents, config)
if err != nil {
return nil, err
}
for _, e := range resp.Embeddings {
vecs = append(vecs, llm.Vector(e.Values).Normal())
}
}
return vecs, nil
}
var _ llm.ContentGenerator = (*Client)(nil)
// Model returns the name of the client's generative model.
func (c *Client) Model() string {
return c.generativeModel
}
// SetTemperature sets the temperature of the client's generative model.
func (c *Client) SetTemperature(t float32) {
c.temperature = t
}
// GenerateContent returns the model's response for the prompt parts,
// implementing [llm.ContentGenerator.GenerateContent].
func (c *Client) GenerateContent(ctx context.Context, schema *llm.Schema, promptParts []llm.Part) (string, error) {
// Generate plain text.
if schema == nil {
text, err := c.generate(ctx, "text/plain", nil, promptParts...)
if err != nil {
return "", fmt.Errorf("gemini.GenerateContent: %w", err)
}
return text, nil
}
// Generate JSON.
text, err := c.generate(ctx, "application/json", genaiSchema(schema), promptParts...)
if err != nil {
return "", fmt.Errorf("gemini.GenerateContent: %w", err)
}
return text, nil
}
// generate returns the model's response (of the specified MIME type) for the prompt parts.
// It returns an error if a response cannot be generated.
func (c *Client) generate(ctx context.Context, mimeType string, schema *genai.Schema, promptParts ...llm.Part) (string, error) {
parts, err := c.parts(promptParts)
if err != nil {
return "", err
}
config := &genai.GenerateContentConfig{
CandidateCount: 1,
ResponseMIMEType: mimeType,
ResponseSchema: schema,
}
if c.temperature >= 0 {
config.Temperature = &c.temperature
}
resp, err := c.genai.Models.GenerateContent(ctx, c.generativeModel, []*genai.Content{{Role: genai.RoleUser, Parts: parts}}, config)
if err != nil {
return "", err
}
text := resp.Text()
if text == "" {
return "", errors.New("no content generated")
}
return text, nil
}
// parts converts the given prompt parts to [genai.Part]s of
// their corresponding type.
func (c *Client) parts(promptParts []llm.Part) ([]*genai.Part, error) {
var parts = make([]*genai.Part, len(promptParts))
for i, p := range promptParts {
switch p := p.(type) {
case llm.Text:
parts[i] = genai.NewPartFromText(string(p))
case llm.Blob:
parts[i] = genai.NewPartFromBytes(p.Data, p.MIMEType)
default:
return nil, fmt.Errorf("bad type for part: %T; need string or llm.Blob", p)
}
}
return parts, nil
}