313 lines
9.5 KiB
Go
313 lines
9.5 KiB
Go
// Package anthropic implements provider.Adapter for the Anthropic Messages API.
|
|
// The Anthropic API differs from OpenAI in:
|
|
// - Endpoint: /v1/messages (not /chat/completions)
|
|
// - Auth headers: x-api-key + anthropic-version (not Authorization: Bearer)
|
|
// - Request: system prompt as top-level field, max_tokens required
|
|
// - Response: content[].text instead of choices[].message.content
|
|
// - Streaming: event-typed SSE (content_block_delta, message_stop) re-emitted as OpenAI-compat
|
|
package anthropic
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/veylant/ia-gateway/internal/apierror"
|
|
"github.com/veylant/ia-gateway/internal/provider"
|
|
)
|
|
|
|
const (
|
|
defaultBaseURL = "https://api.anthropic.com/v1"
|
|
defaultVersion = "2023-06-01"
|
|
defaultMaxTokens = 4096
|
|
)
|
|
|
|
// Config holds Anthropic adapter configuration.
|
|
type Config struct {
|
|
APIKey string
|
|
BaseURL string // default: https://api.anthropic.com/v1
|
|
Version string // Anthropic-Version header, e.g. "2023-06-01"
|
|
TimeoutSeconds int
|
|
MaxConns int
|
|
}
|
|
|
|
// Adapter implements provider.Adapter for the Anthropic Messages API.
|
|
type Adapter struct {
|
|
cfg Config
|
|
testBaseURL string // non-empty in tests: overrides BaseURL
|
|
regularClient *http.Client
|
|
streamingClient *http.Client
|
|
}
|
|
|
|
// anthropicRequest is the wire format for the Anthropic Messages API.
|
|
type anthropicRequest struct {
|
|
Model string `json:"model"`
|
|
MaxTokens int `json:"max_tokens"`
|
|
System string `json:"system,omitempty"`
|
|
Messages []provider.Message `json:"messages"`
|
|
Stream bool `json:"stream,omitempty"`
|
|
}
|
|
|
|
// anthropicResponse is the non-streaming response from the Anthropic Messages API.
|
|
type anthropicResponse struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Model string `json:"model"`
|
|
Content []anthropicContent `json:"content"`
|
|
StopReason string `json:"stop_reason"`
|
|
Usage anthropicUsage `json:"usage"`
|
|
}
|
|
|
|
type anthropicContent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type anthropicUsage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
}
|
|
|
|
// New creates an Anthropic adapter.
|
|
func New(cfg Config) *Adapter {
|
|
return NewWithBaseURL("", cfg)
|
|
}
|
|
|
|
// NewWithBaseURL creates an adapter with a custom base URL — used in tests to
|
|
// point the adapter at a mock server instead of the real Anthropic endpoint.
|
|
func NewWithBaseURL(baseURL string, cfg Config) *Adapter {
|
|
if cfg.BaseURL == "" {
|
|
cfg.BaseURL = defaultBaseURL
|
|
}
|
|
if cfg.Version == "" {
|
|
cfg.Version = defaultVersion
|
|
}
|
|
|
|
dialer := &net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
}
|
|
transport := &http.Transport{
|
|
DialContext: dialer.DialContext,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ResponseHeaderTimeout: 30 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
MaxIdleConns: cfg.MaxConns,
|
|
MaxIdleConnsPerHost: cfg.MaxConns,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
}
|
|
|
|
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
|
|
if cfg.TimeoutSeconds == 0 {
|
|
timeout = 30 * time.Second
|
|
}
|
|
|
|
return &Adapter{
|
|
cfg: cfg,
|
|
testBaseURL: baseURL,
|
|
regularClient: &http.Client{
|
|
Transport: transport,
|
|
Timeout: timeout,
|
|
},
|
|
streamingClient: &http.Client{
|
|
Transport: transport,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Validate checks that req is well-formed for the Anthropic adapter.
|
|
func (a *Adapter) Validate(req *provider.ChatRequest) error {
|
|
if req.Model == "" {
|
|
return apierror.NewBadRequestError("field 'model' is required")
|
|
}
|
|
if len(req.Messages) == 0 {
|
|
return apierror.NewBadRequestError("field 'messages' must not be empty")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Send performs a non-streaming chat completion request to the Anthropic Messages API.
|
|
func (a *Adapter) Send(ctx context.Context, req *provider.ChatRequest) (*provider.ChatResponse, error) {
|
|
anthropicReq := toAnthropicRequest(req)
|
|
|
|
body, err := json.Marshal(anthropicReq)
|
|
if err != nil {
|
|
return nil, apierror.NewInternalError(fmt.Sprintf("marshaling request: %v", err))
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.messagesURL(), bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, apierror.NewInternalError(fmt.Sprintf("building request: %v", err))
|
|
}
|
|
a.setHeaders(httpReq)
|
|
|
|
resp, err := a.regularClient.Do(httpReq)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil, apierror.NewTimeoutError("Anthropic request timed out")
|
|
}
|
|
return nil, apierror.NewUpstreamError(fmt.Sprintf("calling Anthropic: %v", err))
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, mapUpstreamStatus(resp.StatusCode)
|
|
}
|
|
|
|
var anthropicResp anthropicResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&anthropicResp); err != nil {
|
|
return nil, apierror.NewUpstreamError(fmt.Sprintf("decoding response: %v", err))
|
|
}
|
|
return fromAnthropicResponse(&anthropicResp), nil
|
|
}
|
|
|
|
// Stream performs a streaming chat completion request and pipes normalized SSE chunks to w.
|
|
// Anthropic's event-typed SSE format is converted to OpenAI-compatible chunks on the fly.
|
|
func (a *Adapter) Stream(ctx context.Context, req *provider.ChatRequest, w http.ResponseWriter) error {
|
|
anthropicReq := toAnthropicRequest(req)
|
|
anthropicReq.Stream = true
|
|
|
|
body, err := json.Marshal(anthropicReq)
|
|
if err != nil {
|
|
return apierror.NewInternalError(fmt.Sprintf("marshaling stream request: %v", err))
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.messagesURL(), bytes.NewReader(body))
|
|
if err != nil {
|
|
return apierror.NewInternalError(fmt.Sprintf("building stream request: %v", err))
|
|
}
|
|
a.setHeaders(httpReq)
|
|
|
|
resp, err := a.streamingClient.Do(httpReq)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return apierror.NewTimeoutError("Anthropic stream timed out")
|
|
}
|
|
return apierror.NewUpstreamError(fmt.Sprintf("opening stream to Anthropic: %v", err))
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return mapUpstreamStatus(resp.StatusCode)
|
|
}
|
|
|
|
return PipeAnthropicSSE(bufio.NewScanner(resp.Body), w)
|
|
}
|
|
|
|
// HealthCheck verifies the Anthropic API is reachable by listing models.
|
|
func (a *Adapter) HealthCheck(ctx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL()+"/models", nil)
|
|
if err != nil {
|
|
return fmt.Errorf("building Anthropic health check request: %w", err)
|
|
}
|
|
a.setHeaders(req)
|
|
|
|
resp, err := a.regularClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("Anthropic health check failed: %w", err)
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("Anthropic health check returned HTTP %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// messagesURL returns the Anthropic Messages API endpoint.
|
|
func (a *Adapter) messagesURL() string {
|
|
return a.baseURL() + "/messages"
|
|
}
|
|
|
|
// baseURL returns testBaseURL when set (tests), otherwise cfg.BaseURL.
|
|
func (a *Adapter) baseURL() string {
|
|
if a.testBaseURL != "" {
|
|
return a.testBaseURL
|
|
}
|
|
return a.cfg.BaseURL
|
|
}
|
|
|
|
// setHeaders attaches required Anthropic authentication and version headers.
|
|
func (a *Adapter) setHeaders(req *http.Request) {
|
|
req.Header.Set("x-api-key", a.cfg.APIKey)
|
|
req.Header.Set("anthropic-version", a.cfg.Version)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
}
|
|
|
|
// toAnthropicRequest converts an OpenAI-compat ChatRequest to Anthropic Messages API format.
|
|
// System-role messages are extracted to the top-level "system" field.
|
|
// max_tokens defaults to 4096 if not set in the original request.
|
|
func toAnthropicRequest(req *provider.ChatRequest) anthropicRequest {
|
|
var systemParts []string
|
|
var messages []provider.Message
|
|
|
|
for _, m := range req.Messages {
|
|
if m.Role == "system" {
|
|
systemParts = append(systemParts, m.Content)
|
|
} else {
|
|
messages = append(messages, m)
|
|
}
|
|
}
|
|
|
|
maxTokens := defaultMaxTokens
|
|
if req.MaxTokens != nil {
|
|
maxTokens = *req.MaxTokens
|
|
}
|
|
|
|
return anthropicRequest{
|
|
Model: req.Model,
|
|
MaxTokens: maxTokens,
|
|
System: strings.Join(systemParts, "\n"),
|
|
Messages: messages,
|
|
}
|
|
}
|
|
|
|
// fromAnthropicResponse converts an Anthropic non-streaming response to the
|
|
// OpenAI-compatible ChatResponse format used internally.
|
|
func fromAnthropicResponse(r *anthropicResponse) *provider.ChatResponse {
|
|
var content string
|
|
if len(r.Content) > 0 {
|
|
content = r.Content[0].Text
|
|
}
|
|
return &provider.ChatResponse{
|
|
ID: r.ID,
|
|
Object: "chat.completion",
|
|
Model: r.Model,
|
|
Choices: []provider.Choice{{
|
|
Index: 0,
|
|
Message: provider.Message{Role: "assistant", Content: content},
|
|
FinishReason: r.StopReason,
|
|
}},
|
|
Usage: provider.Usage{
|
|
PromptTokens: r.Usage.InputTokens,
|
|
CompletionTokens: r.Usage.OutputTokens,
|
|
TotalTokens: r.Usage.InputTokens + r.Usage.OutputTokens,
|
|
},
|
|
}
|
|
}
|
|
|
|
func mapUpstreamStatus(status int) *apierror.APIError {
|
|
switch status {
|
|
case http.StatusUnauthorized:
|
|
return apierror.NewAuthError("Anthropic rejected the API key")
|
|
case http.StatusTooManyRequests:
|
|
return apierror.NewRateLimitError("Anthropic rate limit reached")
|
|
case http.StatusBadRequest:
|
|
return apierror.NewBadRequestError("Anthropic rejected the request")
|
|
case http.StatusGatewayTimeout, http.StatusRequestTimeout:
|
|
return apierror.NewTimeoutError("Anthropic request timed out")
|
|
default:
|
|
return apierror.NewUpstreamError(fmt.Sprintf("Anthropic returned HTTP %d", status))
|
|
}
|
|
}
|