237 lines
6.0 KiB
Go
237 lines
6.0 KiB
Go
package providers
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const anthropicDefaultEndpoint = "https://api.anthropic.com"
|
|
const anthropicAPIVersion = "2023-06-01"
|
|
|
|
// AnthropicProvider handles the Anthropic Messages API.
|
|
type AnthropicProvider struct{}
|
|
|
|
func (p *AnthropicProvider) ID() string { return "anthropic" }
|
|
|
|
// ── Chat Completion (non-streaming) ─────────
|
|
|
|
func (p *AnthropicProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) {
|
|
req.Stream = false
|
|
|
|
body, err := p.doRequest(ctx, cfg, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer body.Close()
|
|
|
|
var resp anthropicResponse
|
|
if err := json.NewDecoder(body).Decode(&resp); err != nil {
|
|
return nil, fmt.Errorf("decode response: %w", err)
|
|
}
|
|
|
|
// Extract text from content blocks
|
|
var content string
|
|
for _, block := range resp.Content {
|
|
if block.Type == "text" {
|
|
content += block.Text
|
|
}
|
|
}
|
|
|
|
return &CompletionResponse{
|
|
Content: content,
|
|
Model: resp.Model,
|
|
FinishReason: resp.StopReason,
|
|
InputTokens: resp.Usage.InputTokens,
|
|
OutputTokens: resp.Usage.OutputTokens,
|
|
}, nil
|
|
}
|
|
|
|
// ── Stream Completion ───────────────────────
|
|
|
|
func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) {
|
|
req.Stream = true
|
|
|
|
body, err := p.doRequest(ctx, cfg, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ch := make(chan StreamEvent, 64)
|
|
|
|
go func() {
|
|
defer close(ch)
|
|
defer body.Close()
|
|
|
|
scanner := bufio.NewScanner(body)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
|
|
var event anthropicStreamEvent
|
|
if err := json.Unmarshal([]byte(data), &event); err != nil {
|
|
continue
|
|
}
|
|
|
|
switch event.Type {
|
|
case "content_block_delta":
|
|
if event.Delta.Type == "text_delta" {
|
|
ch <- StreamEvent{Delta: event.Delta.Text}
|
|
}
|
|
case "message_delta":
|
|
ch <- StreamEvent{
|
|
Done: true,
|
|
FinishReason: event.Delta.StopReason,
|
|
}
|
|
case "message_stop":
|
|
ch <- StreamEvent{Done: true}
|
|
return
|
|
case "error":
|
|
ch <- StreamEvent{
|
|
Error: fmt.Errorf("anthropic stream error: %s", data),
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
ch <- StreamEvent{Error: err}
|
|
}
|
|
}()
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
// ── List Models ─────────────────────────────
|
|
|
|
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) {
|
|
// Anthropic doesn't have a list models endpoint.
|
|
// Return the known model families.
|
|
return []Model{
|
|
{ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"},
|
|
{ID: "claude-haiku-4-20250414", Name: "Claude Haiku 4"},
|
|
{ID: "claude-opus-4-20250514", Name: "Claude Opus 4"},
|
|
{ID: "claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"},
|
|
{ID: "claude-3-5-haiku-20241022", Name: "Claude 3.5 Haiku"},
|
|
}, nil
|
|
}
|
|
|
|
// ── HTTP Layer ──────────────────────────────
|
|
|
|
func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (io.ReadCloser, error) {
|
|
endpoint := cfg.Endpoint
|
|
if endpoint == "" {
|
|
endpoint = anthropicDefaultEndpoint
|
|
}
|
|
endpoint = strings.TrimSuffix(endpoint, "/") + "/v1/messages"
|
|
|
|
// Separate system message from conversation
|
|
var system string
|
|
messages := make([]anthropicMessage, 0, len(req.Messages))
|
|
for _, m := range req.Messages {
|
|
if m.Role == "system" {
|
|
system = m.Content
|
|
continue
|
|
}
|
|
messages = append(messages, anthropicMessage{
|
|
Role: m.Role,
|
|
Content: m.Content,
|
|
})
|
|
}
|
|
|
|
// Build Anthropic-format request
|
|
antReq := anthropicRequest{
|
|
Model: req.Model,
|
|
Messages: messages,
|
|
MaxTokens: req.MaxTokens,
|
|
Stream: req.Stream,
|
|
}
|
|
if system != "" {
|
|
antReq.System = system
|
|
}
|
|
if antReq.MaxTokens == 0 {
|
|
antReq.MaxTokens = 4096 // Anthropic requires max_tokens
|
|
}
|
|
if req.Temperature != nil {
|
|
antReq.Temperature = req.Temperature
|
|
}
|
|
if req.TopP != nil {
|
|
antReq.TopP = req.TopP
|
|
}
|
|
|
|
body, err := json.Marshal(antReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal request: %w", err)
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
httpReq.Header.Set("x-api-key", cfg.APIKey)
|
|
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
|
|
|
|
resp, err := http.DefaultClient.Do(httpReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("provider request: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("provider error: HTTP %d: %s", resp.StatusCode, string(b))
|
|
}
|
|
|
|
return resp.Body, nil
|
|
}
|
|
|
|
// ── Anthropic Wire Types ────────────────────
|
|
|
|
type anthropicMessage struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type anthropicRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []anthropicMessage `json:"messages"`
|
|
System string `json:"system,omitempty"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
Temperature *float64 `json:"temperature,omitempty"`
|
|
TopP *float64 `json:"top_p,omitempty"`
|
|
Stream bool `json:"stream"`
|
|
}
|
|
|
|
type anthropicResponse struct {
|
|
Model string `json:"model"`
|
|
StopReason string `json:"stop_reason"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
Usage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
} `json:"usage"`
|
|
}
|
|
|
|
type anthropicStreamEvent struct {
|
|
Type string `json:"type"`
|
|
Delta struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
StopReason string `json:"stop_reason"`
|
|
} `json:"delta"`
|
|
}
|