package llm import ( "context" "fmt" "google.golang.org/genai" ) // GeminiClient 通过 google.golang.org/genai SDK 调用 Generative AI API。 // baseURL 可改写以走 Vertex AI 或内部网关。 type GeminiClient struct { sdk *genai.Client } // Compile-time assertion: GeminiClient must satisfy LLMClient + Embedder. var ( _ LLMClient = (*GeminiClient)(nil) _ Embedder = (*GeminiClient)(nil) ) // NewGemini 构造 GeminiClient。apiKey 不可空;baseURL 可空(用 SDK 默认端点)。 func NewGemini(apiKey, baseURL string) (*GeminiClient, error) { cfg := &genai.ClientConfig{ APIKey: apiKey, Backend: genai.BackendGeminiAPI, } if baseURL != "" { // SDK v1.55: HTTPOptions.BaseURL 改写 API 端点,用于 httptest 或自定义网关。 cfg.HTTPOptions = genai.HTTPOptions{BaseURL: baseURL} } c, err := genai.NewClient(context.Background(), cfg) if err != nil { return nil, fmt.Errorf("gemini client: %w", err) } return &GeminiClient{sdk: c}, nil } func (c *GeminiClient) Provider() string { return "gemini" } // toGeminiContents 把中立 Message 切片转换为 SDK *Content 切片。 // image/document 的 Data 字段为原始字节,Gemini SDK 直接接受(InlineData 不需要 base64 编码)。 func toGeminiContents(req Request) []*genai.Content { out := make([]*genai.Content, 0, len(req.Messages)) for _, m := range req.Messages { role := "user" if m.Role == RoleAssistant { role = "model" } parts := make([]*genai.Part, 0, len(m.Blocks)) for _, b := range m.Blocks { switch b.Type { case "text": parts = append(parts, genai.NewPartFromText(b.Text)) case "image", "document": // SDK v1.55: Blob.MIMEType 大写,Data 为原始字节(SDK 内部处理编码)。 parts = append(parts, &genai.Part{InlineData: &genai.Blob{ MIMEType: b.MimeType, Data: b.Data, }}) } } out = append(out, &genai.Content{Role: role, Parts: parts}) } return out } func (c *GeminiClient) Stream(ctx context.Context, modelID string, req Request) (<-chan StreamEvent, error) { cfg := &genai.GenerateContentConfig{ MaxOutputTokens: int32(req.MaxOutputTokens), } if req.SystemPrompt != "" { cfg.SystemInstruction = &genai.Content{ Parts: []*genai.Part{genai.NewPartFromText(req.SystemPrompt)}, } } if req.Reasoning { // SDK v1.55: ThinkingConfig.IncludeThoughts 是 bool,不是 *bool;无需 genai.Ptr()。 cfg.ThinkingConfig = &genai.ThinkingConfig{IncludeThoughts: true} } // SDK v1.55: GenerateContentStream 返回 iter.Seq2[*GenerateContentResponse, error], // 可用 Go 1.23+ range-over-func 语法直接遍历。 iter := c.sdk.Models.GenerateContentStream(ctx, modelID, toGeminiContents(req), cfg) out := make(chan StreamEvent, 16) // send 在 ctx 取消时立即放弃发送,防止下游 reader 已退出导致 goroutine 永久阻塞。 send := func(ev StreamEvent) bool { select { case <-ctx.Done(): return false case out <- ev: return true } } go func() { defer close(out) var lastReason string for resp, err := range iter { if err != nil { send(StreamEvent{Type: EventError, Err: fmt.Errorf("gemini stream: %w", err)}) return } if len(resp.Candidates) == 0 { continue } cand := resp.Candidates[0] if cand.Content != nil { for _, p := range cand.Content.Parts { if p.Thought { if !send(StreamEvent{Type: EventThinkingDelta, Text: p.Text}) { return } } else if p.Text != "" { if !send(StreamEvent{Type: EventTextDelta, Text: p.Text}) { return } } } } if resp.UsageMetadata != nil { if !send(StreamEvent{Type: EventUsage, Usage: &Usage{ PromptTokens: int(resp.UsageMetadata.PromptTokenCount), CompletionTokens: int(resp.UsageMetadata.CandidatesTokenCount), ThinkingTokens: int(resp.UsageMetadata.ThoughtsTokenCount), }}) { return } } if cand.FinishReason != "" { lastReason = string(cand.FinishReason) } } send(StreamEvent{Type: EventDone, Reason: lastReason}) }() return out, nil } // CountTokens 调用 Gemini 远程 CountTokens 端点计算 token 数。 func (c *GeminiClient) CountTokens(ctx context.Context, modelID string, req Request) (int, error) { res, err := c.sdk.Models.CountTokens(ctx, modelID, toGeminiContents(req), nil) if err != nil { return 0, fmt.Errorf("gemini count_tokens: %w", err) } return int(res.TotalTokens), nil } // EmbedDims returns the platform-standard embedding dimension. gemini-embedding // honors OutputDimensionality so Embed pins the output to PlatformEmbeddingDims. func (c *GeminiClient) EmbedDims(string) int { return PlatformEmbeddingDims } // Embed calls EmbedContent with all inputs batched as separate Contents, pinning // OutputDimensionality to the fixed pgvector column width. Returns one vector per // input in order. func (c *GeminiClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) { if len(inputs) == 0 { return nil, nil } contents := make([]*genai.Content, len(inputs)) for i, in := range inputs { contents[i] = &genai.Content{Role: "user", Parts: []*genai.Part{genai.NewPartFromText(in)}} } dims := int32(PlatformEmbeddingDims) resp, err := c.sdk.Models.EmbedContent(ctx, modelID, contents, &genai.EmbedContentConfig{ OutputDimensionality: &dims, }) if err != nil { return nil, fmt.Errorf("gemini embeddings: %w", err) } if len(resp.Embeddings) != len(inputs) { return nil, fmt.Errorf("gemini embeddings: got %d vectors for %d inputs", len(resp.Embeddings), len(inputs)) } out := make([][]float32, len(resp.Embeddings)) for i, e := range resp.Embeddings { v := make([]float32, len(e.Values)) copy(v, e.Values) out[i] = v } return out, nil }