236
server/providers/anthropic.go
Normal file
236
server/providers/anthropic.go
Normal file
@@ -0,0 +1,236 @@
|
||||
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"`
|
||||
}
|
||||
Reference in New Issue
Block a user