feat: Google (Gemini) provider on the official Gen AI SDK

Phase 4: provider/google on google.golang.org/genai v1.59.0 — lazy cached
client, FunctionResponse tool loop, raw-JSON-schema tools and structured
output, ThinkingLevel reasoning mapping, iter.Pull2 streaming, hermetic
httptest suite via HTTPOptions.BaseURL. Registry wires google + gemini
schemes to the real client; stub machinery deleted (all built-ins real).
ADR-0011; README matrix + CLAUDE.md synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:04:28 +02:00
parent 043249e0e1
commit 1ca607906d
11 changed files with 1245 additions and 59 deletions
+140
View File
@@ -0,0 +1,140 @@
package google
import (
"context"
"encoding/json"
"io"
"iter"
"strconv"
"sync"
"google.golang.org/genai"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// Stream implements llm.Model over the SDK's range-over-func stream
// (iter.Seq2), adapted to majordomo's pull-based Stream via iter.Pull2.
func (m *model) Stream(ctx context.Context, req llm.Request, opts ...llm.Option) (llm.Stream, error) {
req = req.Apply(opts...)
if err := m.enforceCapabilities(req); err != nil {
return nil, err
}
client, err := m.provider.genaiClient(ctx)
if err != nil {
return nil, err
}
system, contents, err := m.buildContents(req)
if err != nil {
return nil, err
}
cfg, err := m.buildConfig(req, system)
if err != nil {
return nil, err
}
seq := client.Models.GenerateContentStream(ctx, m.id, contents, cfg)
next, stop := iter.Pull2(iter.Seq2[*genai.GenerateContentResponse, error](seq))
return &stream{model: m, next: next, stop: stop}, nil
}
type stream struct {
model *model
next func() (*genai.GenerateContentResponse, error, bool)
stop func()
mu sync.Mutex
closeOnce sync.Once
finished bool
pending []llm.StreamEvent
text []byte
toolCalls []llm.ToolCall
usage llm.Usage
finish genai.FinishReason
}
func (s *stream) Next() (llm.StreamEvent, error) {
s.mu.Lock()
defer s.mu.Unlock()
for {
if len(s.pending) > 0 {
ev := s.pending[0]
s.pending = s.pending[1:]
return ev, nil
}
if s.finished {
return llm.StreamEvent{}, io.EOF
}
chunk, err, ok := s.next()
if !ok {
s.queueFinal()
continue
}
if err != nil {
return llm.StreamEvent{}, s.model.mapError(err)
}
if chunk.UsageMetadata != nil {
s.usage = llm.Usage{
InputTokens: int(chunk.UsageMetadata.PromptTokenCount),
OutputTokens: int(chunk.UsageMetadata.CandidatesTokenCount + chunk.UsageMetadata.ThoughtsTokenCount),
}
}
if len(chunk.Candidates) == 0 {
continue
}
cand := chunk.Candidates[0]
if cand.FinishReason != "" {
s.finish = cand.FinishReason
}
if cand.Content == nil {
continue
}
for _, part := range cand.Content.Parts {
if part == nil {
continue
}
if part.Text != "" && !part.Thought {
s.text = append(s.text, part.Text...)
s.pending = append(s.pending, llm.StreamEvent{TextDelta: part.Text})
}
// Function calls arrive whole per chunk in the Gemini stream.
if fc := part.FunctionCall; fc != nil {
id := fc.ID
if id == "" {
id = "call_" + strconv.Itoa(len(s.toolCalls))
}
args, err := json.Marshal(fc.Args)
if err != nil || len(fc.Args) == 0 {
args = json.RawMessage("{}")
}
call := llm.ToolCall{ID: id, Name: fc.Name, Arguments: args}
s.toolCalls = append(s.toolCalls, call)
s.pending = append(s.pending, llm.StreamEvent{ToolCall: &call})
}
}
}
}
func (s *stream) queueFinal() {
resp := &llm.Response{
Model: s.model.qualified(),
Usage: s.usage,
FinishReason: mapFinish(s.finish, len(s.toolCalls) > 0),
}
if len(s.text) > 0 {
resp.Parts = append(resp.Parts, llm.Text(string(s.text)))
}
if len(s.toolCalls) > 0 {
resp.ToolCalls = append([]llm.ToolCall(nil), s.toolCalls...)
}
s.pending = append(s.pending, llm.StreamEvent{Response: resp})
s.finished = true
}
func (s *stream) Close() error {
s.closeOnce.Do(s.stop)
return nil
}