package llm import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "math/rand" "net/http" "time" "textmachine/backend/internal/obs" ) // provider_anthropic.go is the native Messages API adapter — the first NEW // code on top of the vojo port (Р1: OpenAI-compat слоя недостаточно, нужен // explicit cache_control). It is a thin raw-HTTP adapter like every other // provider here — NOT the official SDK — so retries, timeouts, proxies and // raw usage stay under the one transport discipline the ledger depends on // (обоснование в 03-implementation-notes §3.5). // // Wire facts (проверено 2026-07-04, платформенная документация): // - POST {base}/v1/messages, headers x-api-key + anthropic-version. // - system is a list of text blocks; cache_control {type:"ephemeral", // ttl:"5m"|"1h"} on the LAST block of the stable prefix; max 4 breakpoints. // - usage.input_tokens is ONLY the uncached remainder; // cache_read_input_tokens and cache_creation_input_tokens are separate. // Neutral Usage.PromptTokens is the SUM of the three (см. llm.go invariant). // - cache write bills ×1.25 (5m) / ×2 (1h); read ×0.1. Min cacheable prefix // is model-dependent (короче — кэш молча не создаётся): models.yaml keeps // the per-model value for the assembler. // - thinking bills as output tokens (inside output_tokens, no separate // field) → ReasoningTokens stays 0 here. // - stop_reason: end_turn | max_tokens | stop_sequence | refusal (+ tool_use, // pause_turn — не наши кейсы в Фазе 0). const anthropicVersion = "2023-06-01" // AnthropicConfig configures the adapter. type AnthropicConfig struct { BaseURL string // default https://api.anthropic.com APIKey string Profile RetryProfile // CacheTTL is the cache_control ttl applied at CacheBoundary blocks: // "" (omit — API default 5m), "5m" or "1h". Per-stage tuning comes from // the pipeline config (TTL 5m может истекать между вызовами стадии — // латентности чанков 63–278 с, см. implementation-notes §3.9). CacheTTL string } type anthropicClient struct { base string key string http *http.Client profile RetryProfile cacheTTL string log *slog.Logger } // NewAnthropicClient builds the native Messages API adapter. func NewAnthropicClient(cfg AnthropicConfig, logger *slog.Logger) LLMClient { base := cfg.BaseURL if base == "" { base = "https://api.anthropic.com" } return &anthropicClient{ base: base, key: cfg.APIKey, http: &http.Client{}, profile: cfg.Profile.withDefaults(), cacheTTL: cfg.CacheTTL, log: logger, } } // --- wire types ------------------------------------------------------------- type anthropicCacheControl struct { Type string `json:"type"` TTL string `json:"ttl,omitempty"` } type anthropicTextBlock struct { Type string `json:"type"` Text string `json:"text"` CacheControl *anthropicCacheControl `json:"cache_control,omitempty"` } type anthropicMessage struct { Role string `json:"role"` Content []anthropicTextBlock `json:"content"` } type anthropicRequest struct { Model string `json:"model"` MaxTokens int `json:"max_tokens"` System []anthropicTextBlock `json:"system,omitempty"` Messages []anthropicMessage `json:"messages"` Temperature float64 `json:"temperature,omitempty"` } type anthropicResponse struct { ID string `json:"id"` Model string `json:"model"` Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` StopReason string `json:"stop_reason"` Usage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationInputTokens int `json:"cache_creation_input_tokens"` CacheReadInputTokens int `json:"cache_read_input_tokens"` } `json:"usage"` } func (c *anthropicClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) { wire, err := c.buildRequest(req) if err != nil { return nil, err } payload, err := json.Marshal(wire) if err != nil { return nil, err } var lastErr error for attempt := 0; attempt < c.profile.MaxAttempts; attempt++ { if attempt > 0 { backoff := c.profile.BackoffBase << uint(attempt-1) if backoff > c.profile.BackoffCap { backoff = c.profile.BackoffCap } if ra := retryAfterOf(lastErr); ra > 0 { backoff = ra } backoff += time.Duration(rand.Intn(250)) * time.Millisecond select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(backoff): } } resp, retryable, err := c.attempt(ctx, payload) if err == nil { return resp, nil } lastErr = err if ctx.Err() != nil { return nil, ctx.Err() } if !retryable { return nil, err } if c.log != nil { c.log.WarnContext(ctx, "anthropic attempt failed, will retry", "attempt", attempt+1, "max", c.profile.MaxAttempts, "err", err) } } return nil, fmt.Errorf("anthropic: exhausted %d attempts: %w", c.profile.MaxAttempts, lastErr) } // buildRequest maps the neutral request onto the Messages wire. Leading // system-role messages become system blocks; CacheBoundary flags become // cache_control on that block. Thinking stays off (Фаза 0: Anthropic — только // SFW-роли редактора/эскалации, thinking-глубина не нужна и биллится как // output; ReasoningEffort пока не маппится — задокументированное ограничение). func (c *anthropicClient) buildRequest(req LLMRequest) (*anthropicRequest, error) { wire := &anthropicRequest{ Model: req.Model, MaxTokens: req.MaxTokens, Temperature: req.Temperature, } boundaries := 0 block := func(m Message) anthropicTextBlock { b := anthropicTextBlock{Type: "text", Text: m.Content} if m.CacheBoundary { boundaries++ b.CacheControl = &anthropicCacheControl{Type: "ephemeral", TTL: c.cacheTTL} } return b } inSystemPrefix := true for _, m := range req.Messages { if inSystemPrefix && m.Role == "system" { wire.System = append(wire.System, block(m)) continue } inSystemPrefix = false if m.Role == "system" { // Mid-conversation system turns are model-gated on this API; our // assembler never produces them — fail loud rather than mislabel. return nil, fmt.Errorf("anthropic: system message after non-system turn is not supported") } wire.Messages = append(wire.Messages, anthropicMessage{ Role: m.Role, Content: []anthropicTextBlock{block(m)}, }) } if boundaries > 4 { return nil, fmt.Errorf("anthropic: %d cache boundaries, API allows at most 4", boundaries) } if req.JSONOnly { // Messages API has no response_format json_object; judge roles on // Anthropic would need structured outputs (Фаза 2). Fail loud so a // misconfigured pipeline doesn't silently lose the JSON constraint. return nil, fmt.Errorf("anthropic: JSONOnly is not supported by this adapter yet") } return wire, nil } func (c *anthropicClient) attempt(ctx context.Context, payload []byte) (*LLMResponse, bool, error) { attemptCtx, cancel := context.WithTimeout(ctx, c.profile.AttemptTimeout) defer cancel() req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.base+"/v1/messages", bytes.NewReader(payload)) if err != nil { return nil, false, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("x-api-key", c.key) req.Header.Set("anthropic-version", anthropicVersion) resp, err := c.http.Do(req) if err != nil { return nil, ctx.Err() == nil, err } defer resp.Body.Close() data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) obs.LogLLMExchange(ctx, c.log, "anthropic", payload, resp.StatusCode, data) // 529 (overloaded) is Anthropic's extra retryable status on top of 429/5xx. if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { return nil, true, &HTTPStatusError{Provider: "anthropic", Status: resp.StatusCode, Body: snippet(data), RetryAfter: parseRetryAfter(resp.Header.Get("Retry-After"))} } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, false, &HTTPStatusError{Provider: "anthropic", Status: resp.StatusCode, Body: snippet(data)} } var out anthropicResponse if err := json.Unmarshal(data, &out); err != nil { return nil, false, fmt.Errorf("anthropic decode: %w", err) } var text string for _, b := range out.Content { if b.Type == "text" { text += b.Text } } // A 2xx with empty content (refusal before output, filtered) is still a // billed call — return success and let gates handle the empty text, same // discipline as the OpenAI-compat transport. return &LLMResponse{ Text: text, Usage: Usage{ // Neutral invariant: PromptTokens = total input. Anthropic's // input_tokens is only the uncached remainder — sum the parts. PromptTokens: out.Usage.InputTokens + out.Usage.CacheReadInputTokens + out.Usage.CacheCreationInputTokens, CachedTokens: out.Usage.CacheReadInputTokens, CacheCreationTokens: out.Usage.CacheCreationInputTokens, CompletionTokens: out.Usage.OutputTokens, // ReasoningTokens 0: thinking bills inside output_tokens here. }, Model: out.Model, FinishReason: mapAnthropicStopReason(out.StopReason), ProviderRequestID: out.ID, }, false, nil } func mapAnthropicStopReason(s string) string { switch s { case "end_turn", "stop_sequence": return FinishStop case "max_tokens": return FinishLength case "refusal": return FinishRefusal default: return "" } }