You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

263 lines
6.9 KiB

package gemini
import (
"bufio"
"bytes"
"context"
"yunyan/lego/sys/log"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Gemini Native API
// Docs: https://ai.google.dev/gemini-api/docs
type Gemini struct {
options Options
client *http.Client
}
func newSys(options Options) (sys *Gemini, err error) {
if options.Apikey == "" {
return nil, fmt.Errorf("google gemini: apikey is required")
}
sys = &Gemini{
options: options,
client: &http.Client{Timeout: time.Duration(options.Timeout) * time.Second},
}
return
}
// ==================== 请求/响应结构 ====================
type genPart struct {
Text string `json:"text"`
}
type genContent struct {
Role string `json:"role,omitempty"`
Parts []genPart `json:"parts"`
}
type genSystemInstruction struct {
Parts []genPart `json:"parts"`
}
type generateContentRequest struct {
Contents []genContent `json:"contents"`
SystemInstruction *genSystemInstruction `json:"systemInstruction,omitempty"`
GenerationConfig *genGenerationConfig `json:"generationConfig,omitempty"`
}
type genGenerationConfig struct {
Temperature float32 `json:"temperature,omitempty"`
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
}
type generateContentResponse struct {
Candidates []struct {
Content genContent `json:"content"`
FinishReason string `json:"finishReason"`
} `json:"candidates"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
} `json:"error,omitempty"`
}
// ==================== 实现 ====================
// buildRequest 将 OpenAI 风格的 messages 转为 Gemini 的 contents + systemInstruction
// - role=system → systemInstruction
// - role=user → contents[role=user]
// - role=assistant/ai/model → contents[role=model]
func buildRequest(msgs []Message) *generateContentRequest {
req := &generateContentRequest{
Contents: make([]genContent, 0, len(msgs)),
}
var sysParts []genPart
for _, m := range msgs {
switch strings.ToLower(m.Role) {
case "system":
sysParts = append(sysParts, genPart{Text: m.Content})
case "assistant", "ai", "model":
req.Contents = append(req.Contents, genContent{
Role: "model",
Parts: []genPart{{Text: m.Content}},
})
default: // user / 其他
req.Contents = append(req.Contents, genContent{
Role: "user",
Parts: []genPart{{Text: m.Content}},
})
}
}
if len(sysParts) > 0 {
req.SystemInstruction = &genSystemInstruction{Parts: sysParts}
}
return req
}
func (this *Gemini) endpointFor(action string) string {
return fmt.Sprintf("%s/v1beta/models/%s:%s?key=%s",
strings.TrimRight(this.options.Endpoint, "/"),
this.options.Model,
action,
this.options.Apikey,
)
}
func (this *Gemini) Chat(ctx context.Context, msgs []Message) (result *ChatResponseChoice, err error) {
stime := time.Now()
reqBody := buildRequest(msgs)
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, this.endpointFor("generateContent"), bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := this.client.Do(httpReq)
if err != nil {
this.options.Log.Errorf("gemini chat error: %v", err)
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("gemini chat failed: status=%d body=%s", resp.StatusCode, string(data))
}
var out generateContentResponse
if err = json.Unmarshal(data, &out); err != nil {
return nil, fmt.Errorf("unmarshal response: %w body=%s", err, string(data))
}
if out.Error != nil {
return nil, fmt.Errorf("gemini chat error: code=%d status=%s msg=%s", out.Error.Code, out.Error.Status, out.Error.Message)
}
if len(out.Candidates) == 0 {
return nil, fmt.Errorf("gemini chat no candidates, body=%s", string(data))
}
var sb strings.Builder
for _, p := range out.Candidates[0].Content.Parts {
sb.WriteString(p.Text)
}
result = &ChatResponseChoice{
Role: "ai",
Content: sb.String(),
}
this.options.Log.Debug("[统计]",
log.Field{Key: "t", Value: time.Since(stime).Milliseconds()},
log.Field{Key: "m", Value: "Chat"},
log.Field{Key: "req", Value: msgs},
)
return
}
// ChatForSteams 使用 streamGenerateContent 流式接口,SSE 风格按行返回
func (this *Gemini) ChatForSteams(ctx context.Context, msgs []Message, choiceChan chan *ChatResponseChoice) (err error) {
defer close(choiceChan)
stime := time.Now()
reqBody := buildRequest(msgs)
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
// alt=sse 让 Gemini 以 SSE 形式返回
url := this.endpointFor("streamGenerateContent") + "&alt=sse"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
resp, err := this.client.Do(httpReq)
if err != nil {
this.options.Log.Errorf("gemini stream chat error: %v", err)
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("gemini stream failed: status=%d body=%s", resp.StatusCode, string(data))
}
reader := bufio.NewReader(resp.Body)
for {
line, e := reader.ReadString('\n')
if e != nil && e != io.EOF {
return fmt.Errorf("read stream: %w", e)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
if e == io.EOF {
break
}
continue
}
// SSE 以 "data: " 开头
const prefix = "data:"
if !strings.HasPrefix(line, prefix) {
if e == io.EOF {
break
}
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, prefix))
if payload == "" || payload == "[DONE]" {
if e == io.EOF {
break
}
continue
}
var chunk generateContentResponse
if jsErr := json.Unmarshal([]byte(payload), &chunk); jsErr != nil {
// 一些情况下 Gemini 把 error 放外面,跳过解析失败的行
continue
}
if chunk.Error != nil {
return fmt.Errorf("gemini stream error: code=%d status=%s msg=%s",
chunk.Error.Code, chunk.Error.Status, chunk.Error.Message)
}
if len(chunk.Candidates) == 0 {
if e == io.EOF {
break
}
continue
}
var sb strings.Builder
for _, p := range chunk.Candidates[0].Content.Parts {
sb.WriteString(p.Text)
}
if sb.Len() > 0 {
choiceChan <- &ChatResponseChoice{
Role: "ai",
Content: sb.String(),
}
}
if e == io.EOF {
break
}
}
this.options.Log.Debug("[统计]",
log.Field{Key: "t", Value: time.Since(stime).Milliseconds()},
log.Field{Key: "m", Value: "ChatForSteams"},
)
return nil
}