package llm import ( "context" "encoding/base64" "fmt" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" ) // Compile-time assertion: AnthropicClient must satisfy LLMClient + Embedder // (Embed returns ErrEmbeddingsUnsupported). var ( _ LLMClient = (*AnthropicClient)(nil) _ Embedder = (*AnthropicClient)(nil) ) // AnthropicClient 通过官方 SDK 调用 Anthropic Messages API。 // baseURL 可改写以走 LiteLLM/OpenRouter 等代理。 type AnthropicClient struct { sdk anthropic.Client } // NewAnthropic 构造客户端。baseURL 可空(用 SDK 默认)。 func NewAnthropic(apiKey, baseURL string) (*AnthropicClient, error) { opts := []option.RequestOption{option.WithAPIKey(apiKey)} if baseURL != "" { opts = append(opts, option.WithBaseURL(baseURL)) } c := anthropic.NewClient(opts...) return &AnthropicClient{sdk: c}, nil } func (c *AnthropicClient) Provider() string { return "anthropic" } // toSDKMessages 把中立 Message 转 SDK 类型。 // image/document 的 Data 字段为原始字节,此处进行 base64 编码。 func toSDKMessages(msgs []Message) []anthropic.MessageParam { out := make([]anthropic.MessageParam, 0, len(msgs)) for _, m := range msgs { blocks := make([]anthropic.ContentBlockParamUnion, 0, len(m.Blocks)) for _, b := range m.Blocks { switch b.Type { case "text": blocks = append(blocks, anthropic.NewTextBlock(b.Text)) case "image": // NewImageBlockBase64 期望 base64 字符串,Data 字段存原始字节 encoded := base64.StdEncoding.EncodeToString(b.Data) blocks = append(blocks, anthropic.NewImageBlockBase64(b.MimeType, encoded)) case "document": // NewDocumentBlock 泛型支持 Base64PDFSourceParam,Data 字段存原始字节 encoded := base64.StdEncoding.EncodeToString(b.Data) blocks = append(blocks, anthropic.NewDocumentBlock(anthropic.Base64PDFSourceParam{ Data: encoded, })) } } role := anthropic.MessageParamRoleUser if m.Role == RoleAssistant { role = anthropic.MessageParamRoleAssistant } out = append(out, anthropic.MessageParam{Role: role, Content: blocks}) } return out } func (c *AnthropicClient) Stream(ctx context.Context, modelID string, req Request) (<-chan StreamEvent, error) { params := anthropic.MessageNewParams{ Model: anthropic.Model(modelID), MaxTokens: int64(req.MaxOutputTokens), Messages: toSDKMessages(req.Messages), } if req.SystemPrompt != "" { params.System = []anthropic.TextBlockParam{{Text: req.SystemPrompt}} } if req.Reasoning { params.Thinking = anthropic.ThinkingConfigParamOfEnabled(int64(req.MaxOutputTokens / 2)) } stream := c.sdk.Messages.NewStreaming(ctx, params) 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 inputTokens int for stream.Next() { event := stream.Current() // SDK v1.38: AsAny() returns unexported anyMessageStreamEvent which cannot // be used in a type switch. Use event.Type string + typed As*() methods. switch event.Type { case "message_start": ev := event.AsMessageStart() inputTokens = int(ev.Message.Usage.InputTokens) case "content_block_delta": ev := event.AsContentBlockDelta() switch ev.Delta.Type { case "text_delta": if text := ev.Delta.AsTextDelta().Text; text != "" { if !send(StreamEvent{Type: EventTextDelta, Text: text}) { return } } case "thinking_delta": if thinking := ev.Delta.AsThinkingDelta().Thinking; thinking != "" { if !send(StreamEvent{Type: EventThinkingDelta, Text: thinking}) { return } } } case "message_delta": ev := event.AsMessageDelta() if !send(StreamEvent{Type: EventUsage, Usage: &Usage{ PromptTokens: inputTokens, CompletionTokens: int(ev.Usage.OutputTokens), }}) { return } case "message_stop": if !send(StreamEvent{Type: EventDone, Reason: "end_turn"}) { return } } } if err := stream.Err(); err != nil { send(StreamEvent{Type: EventError, Err: fmt.Errorf("anthropic stream: %w", err)}) } }() return out, nil } func (c *AnthropicClient) CountTokens(ctx context.Context, modelID string, req Request) (int, error) { params := anthropic.MessageCountTokensParams{ Model: anthropic.Model(modelID), Messages: toSDKMessages(req.Messages), } if req.SystemPrompt != "" { // MessageCountTokensParams.System 是 MessageCountTokensParamsSystemUnion, // 不同于 MessageNewParams.System ([]TextBlockParam) params.System = anthropic.MessageCountTokensParamsSystemUnion{ OfTextBlockArray: []anthropic.TextBlockParam{{Text: req.SystemPrompt}}, } } res, err := c.sdk.Messages.CountTokens(ctx, params) if err != nil { return 0, fmt.Errorf("anthropic count_tokens: %w", err) } return int(res.InputTokens), nil } // Embed is unsupported: Anthropic has no embeddings API. Embeddings must target // an OpenAI-compatible or Gemini endpoint; callers degrade to keyword fallback. func (c *AnthropicClient) Embed(context.Context, string, []string) ([][]float32, error) { return nil, ErrEmbeddingsUnsupported } // EmbedDims is 0 since Anthropic produces no embeddings. func (c *AnthropicClient) EmbedDims(string) int { return 0 }