package llm import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" ) // DefaultAnthropicVersion 是 anthropic-version 请求头的默认值。 const DefaultAnthropicVersion = "2023-06-01" // anthropic 是 Anthropic Messages 协议(/v1/messages + SSE)的实现。 // 与 OpenAI 兼容格式的三处关键差异:鉴权用 x-api-key 而非 Bearer; // system 是请求体顶层字段而非一条消息;图片走 source.base64 而非 data URL。 type anthropic struct { opts Options http *http.Client } func newAnthropic(opts Options) *anthropic { if opts.MaxTokens <= 0 { opts.MaxTokens = 1024 } if opts.Temperature <= 0 { opts.Temperature = 0.3 } return &anthropic{opts: opts, http: newHTTPClient()} } type anthropicRequest struct { Model string `json:"model"` Messages []anthropicMessage `json:"messages"` System string `json:"system,omitempty"` Stream bool `json:"stream"` MaxTokens int `json:"max_tokens"` Temperature float64 `json:"temperature,omitempty"` } type anthropicMessage struct { Role string `json:"role"` Content any `json:"content"` } type anthropicBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` Source *anthropicImage `json:"source,omitempty"` } type anthropicImage struct { Type string `json:"type"` MediaType string `json:"media_type"` Data string `json:"data"` } type anthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` } // anthropicEvent 覆盖流式与非流式两种响应体,用不到的字段留空即可。 type anthropicEvent struct { Type string `json:"type"` Message *struct { Usage *anthropicUsage `json:"usage"` Content []struct { Type string `json:"type"` Text string `json:"text"` Thinking string `json:"thinking"` } `json:"content"` } `json:"message"` Delta *struct { Type string `json:"type"` Text string `json:"text"` // thinking_delta 里思考文本在 thinking 字段。 Thinking string `json:"thinking"` } `json:"delta"` Usage *anthropicUsage `json:"usage"` Error *struct { Type string `json:"type"` Message string `json:"message"` } `json:"error"` } // endpoint 兼容三种写法:官方根地址、已带 /v1、以及直接给全 /messages 的代理。 func (a *anthropic) endpoint() string { base := strings.TrimRight(a.opts.BaseURL, "/") switch { case strings.HasSuffix(base, "/messages"): return base case strings.HasSuffix(base, "/v1"): return base + "/messages" default: return base + "/v1/messages" } } func (a *anthropic) setHeaders(req *http.Request, stream bool) { req.Header.Set("Content-Type", "application/json") if stream { req.Header.Set("Accept", "text/event-stream") } else { req.Header.Set("Accept", "application/json") } if a.opts.APIKey != "" { req.Header.Set("x-api-key", a.opts.APIKey) } if a.opts.ExtraHeaders["anthropic-version"] == "" { req.Header.Set("anthropic-version", DefaultAnthropicVersion) } for k, v := range a.opts.ExtraHeaders { req.Header.Set(k, v) } } func (a *anthropic) buildBody(system string, msgs []Message, stream bool) ([]byte, error) { body := anthropicRequest{ Model: a.opts.Model, Stream: stream, MaxTokens: a.opts.MaxTokens, Temperature: a.opts.Temperature, System: strings.TrimSpace(system), } for _, m := range msgs { role := "user" if m.Role == RoleAssistant { role = "assistant" } if len(m.Image) == 0 { body.Messages = append(body.Messages, anthropicMessage{Role: role, Content: m.Text}) continue } text := m.Text if strings.TrimSpace(text) == "" { text = "请看图并回答。" } blocks := []anthropicBlock{ {Type: "text", Text: text}, { Type: "image", Source: &anthropicImage{ Type: "base64", MediaType: "image/jpeg", Data: base64.StdEncoding.EncodeToString(m.Image), }, }, } body.Messages = append(body.Messages, anthropicMessage{Role: role, Content: blocks}) } if len(body.Messages) == 0 { return nil, fmt.Errorf("没有可发送的消息") } raw, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("构造请求失败: %w", err) } return raw, nil } // Chat 发起一次流式对话。 func (a *anthropic) Chat(ctx context.Context, system string, msgs []Message, fn DeltaFunc) (Usage, error) { if len(msgs) == 0 { return Usage{}, fmt.Errorf("没有可发送的消息") } payload, err := a.buildBody(system, msgs, true) if err != nil { return Usage{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(payload)) if err != nil { return Usage{}, fmt.Errorf("创建请求失败: %w", err) } a.setHeaders(req, true) resp, err := a.http.Do(req) if err != nil { return Usage{}, fmt.Errorf("请求 %s 失败: %w", a.opts.Name, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) return Usage{}, fmt.Errorf("模型返回 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } // 部分代理会忽略 stream 参数直接返回完整 JSON,这里做兼容。 if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "event-stream") { return a.readWhole(resp.Body, fn) } var usage Usage var got strings.Builder var gotReasoning strings.Builder err = readSSE(resp.Body, func(ev sseEvent) error { if len(ev.Data) == 0 { return nil } var e anthropicEvent if json.Unmarshal(ev.Data, &e) != nil { return nil // 忽略无法解析的心跳/杂项 } switch e.Type { case "error": msg := "未知错误" if e.Error != nil && e.Error.Message != "" { msg = e.Error.Message } return fmt.Errorf("模型返回错误: %s", msg) case "message_start": if e.Message != nil && e.Message.Usage != nil { usage.PromptTokens = e.Message.Usage.InputTokens usage.CompletionTokens = e.Message.Usage.OutputTokens } case "content_block_delta": if e.Delta == nil { return nil } // thinking_delta 把思考文本放在 thinking 字段,text_delta 才是正式回答。 if e.Delta.Thinking != "" { gotReasoning.WriteString(e.Delta.Thinking) if fn != nil { if derr := fn(Delta{Reasoning: e.Delta.Thinking}); derr != nil { return derr } } return nil } // input_json_delta 等其它类型走别的字段,直接跳过。 if e.Delta.Text == "" { return nil } got.WriteString(e.Delta.Text) if fn != nil { if derr := fn(Delta{Text: e.Delta.Text}); derr != nil { return derr } } case "message_delta": // 结束前的这一帧才带最终的 output_tokens。 if e.Usage != nil && e.Usage.OutputTokens > 0 { usage.CompletionTokens = e.Usage.OutputTokens } } return nil }) if err != nil { return usage, err } usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens if got.Len() == 0 && gotReasoning.Len() == 0 { return usage, fmt.Errorf("模型没有返回任何内容,请确认 %s 是支持图片输入的视觉模型", a.opts.Model) } return usage, nil } // readWhole 处理服务端忽略 stream 参数的情况。 func (a *anthropic) readWhole(r io.Reader, fn DeltaFunc) (Usage, error) { raw, err := io.ReadAll(io.LimitReader(r, 8<<20)) if err != nil { return Usage{}, fmt.Errorf("读取响应失败: %w", err) } var e anthropicEvent if err := json.Unmarshal(raw, &e); err != nil { return Usage{}, fmt.Errorf("解析响应失败: %w", err) } if e.Error != nil { return Usage{}, fmt.Errorf("模型返回错误: %s", e.Error.Message) } var usage Usage var text strings.Builder var thinking strings.Builder if e.Message != nil { if e.Message.Usage != nil { usage.PromptTokens = e.Message.Usage.InputTokens usage.CompletionTokens = e.Message.Usage.OutputTokens } for _, b := range e.Message.Content { switch b.Type { case "text": text.WriteString(b.Text) case "thinking": thinking.WriteString(b.Thinking) } } } usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens if text.Len() == 0 && thinking.Len() == 0 { return usage, fmt.Errorf("模型返回内容为空") } if fn != nil { if thinking.Len() > 0 { if err := fn(Delta{Reasoning: thinking.String()}); err != nil { return usage, err } } if text.Len() > 0 { if err := fn(Delta{Text: text.String()}); err != nil { return usage, err } } } return usage, nil } // Ping 做一次最小代价的连通性检查。 func (a *anthropic) Ping(ctx context.Context) error { payload, err := a.buildBody("", []Message{{Role: RoleUser, Text: "ping"}}, false) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(payload)) if err != nil { return fmt.Errorf("创建请求失败: %w", err) } a.setHeaders(req, false) resp, err := a.http.Do(req) if err != nil { return fmt.Errorf("连接 %s 失败: %w", a.opts.BaseURL, err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } if len(raw) == 0 { return fmt.Errorf("服务返回空响应") } // 有些代理会把错误塞进 200 的响应体里。 var probe anthropicEvent if json.Unmarshal(raw, &probe) == nil && probe.Error != nil { return fmt.Errorf("模型返回错误: %s", probe.Error.Message) } return nil }