// 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)) } }