diff --git a/go.mod b/go.mod index 75afb77..04c18d1 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,9 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/anthropics/anthropic-sdk-go v1.38.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -36,6 +39,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -44,6 +48,7 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect github.com/moby/moby/api v1.54.1 // indirect @@ -67,8 +72,13 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/testcontainers/testcontainers-go v0.42.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect diff --git a/go.sum b/go.sum index 00a85d2..da4326a 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,12 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= +github.com/anthropics/anthropic-sdk-go v1.38.0 h1:bA4DcK+91gorIX+5VTONnynyt9LRU4nnN6rRQ+j/NIg= +github.com/anthropics/anthropic-sdk-go v1.38.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -65,6 +71,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -75,6 +83,7 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -87,6 +96,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -156,10 +167,22 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= diff --git a/internal/infra/llm/anthropic.go b/internal/infra/llm/anthropic.go new file mode 100644 index 0000000..bde46f7 --- /dev/null +++ b/internal/infra/llm/anthropic.go @@ -0,0 +1,137 @@ +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. +var _ LLMClient = (*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) + + 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 != "" { + out <- StreamEvent{Type: EventTextDelta, Text: text} + } + case "thinking_delta": + if thinking := ev.Delta.AsThinkingDelta().Thinking; thinking != "" { + out <- StreamEvent{Type: EventThinkingDelta, Text: thinking} + } + } + case "message_delta": + ev := event.AsMessageDelta() + out <- StreamEvent{Type: EventUsage, Usage: &Usage{ + PromptTokens: inputTokens, + CompletionTokens: int(ev.Usage.OutputTokens), + }} + case "message_stop": + out <- StreamEvent{Type: EventDone, Reason: "end_turn"} + } + } + if err := stream.Err(); err != nil { + out <- 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 +} diff --git a/internal/infra/llm/anthropic_test.go b/internal/infra/llm/anthropic_test.go new file mode 100644 index 0000000..e6dd2e7 --- /dev/null +++ b/internal/infra/llm/anthropic_test.go @@ -0,0 +1,80 @@ +package llm + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const anthropicStreamFixture = `event: message_start +data: {"type":"message_start","message":{"id":"m1","content":[],"model":"claude-opus","role":"assistant","stop_reason":null,"stop_sequence":null,"type":"message","usage":{"input_tokens":10,"output_tokens":0}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" world"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":2}} + +event: message_stop +data: {"type":"message_stop"} + +` + +func TestAnthropic_Stream_TextDeltaAndDone(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/messages", r.URL.Path) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + _, _ = w.Write([]byte(anthropicStreamFixture)) + })) + defer srv.Close() + + c, err := NewAnthropic("test-key", srv.URL) + require.NoError(t, err) + + req := Request{ + Messages: []Message{{Role: RoleUser, Blocks: []ContentBlock{{Type: "text", Text: "hi"}}}}, + MaxOutputTokens: 100, + } + ch, err := c.Stream(context.Background(), "claude-opus-4-7", req) + require.NoError(t, err) + + var deltas []string + var sawUsage, sawDone bool + var promptTokens, completionTokens int + for ev := range ch { + switch ev.Type { + case EventTextDelta: + deltas = append(deltas, ev.Text) + case EventUsage: + sawUsage = true + promptTokens = ev.Usage.PromptTokens + completionTokens = ev.Usage.CompletionTokens + case EventDone: + sawDone = true + } + } + require.Equal(t, "Hello world", strings.Join(deltas, "")) + require.True(t, sawUsage) + require.Equal(t, 10, promptTokens) + require.Equal(t, 2, completionTokens) + require.True(t, sawDone) +} + +func TestAnthropic_Provider(t *testing.T) { + c, _ := NewAnthropic("k", "http://localhost") + require.Equal(t, "anthropic", c.Provider()) +}