textmachine/backend/internal/llm/provider_anthropic.go

269 lines
9.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"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 может истекать между вызовами стадии —
// латентности чанков 63278 с, см. 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"`
}
// anthropicRequest deliberately carries NO sampling parameters: актуальные
// Claude-модели (Sonnet 5, Opus 4.7+) отклоняют temperature/top_p с 400 —
// управление стилем идёт промптом. Конфигная temperature стадии остаётся в
// request-hash (детерминизм ключа), но на этот wire не попадает.
type anthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System []anthropicTextBlock `json:"system,omitempty"`
Messages []anthropicMessage `json:"messages"`
}
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
}
return retryLoop(ctx, c.profile, "anthropic", c.log, func() (*LLMResponse, bool, error) {
return c.attempt(ctx, payload)
})
}
// 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 is not mapped yet (Фаза 0: Anthropic —
// только SFW-роли редактора/эскалации): ""/"off" проходят (без thinking-
// конфигурации), а low/medium/high падают громко — молча оплаченная стадия БЕЗ
// заказанной глубины рассуждений хуже ошибки конфига.
func (c *anthropicClient) buildRequest(req LLMRequest) (*anthropicRequest, error) {
switch req.ReasoningEffort {
case "", "off":
default:
return nil, fmt.Errorf("anthropic: reasoning %q is not mapped by this adapter yet (thinking — Фаза 2)", req.ReasoningEffort)
}
wire := &anthropicRequest{
Model: req.Model,
MaxTokens: req.MaxTokens,
}
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+1))
truncated := len(data) > maxResponseBytes
if truncated {
data = data[: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 {
// 2xx уже оплачен провайдером — типизируем (см. httpllm.go); усечение
// терминально, обрыв/мусор — retryable.
if truncated {
return nil, false, &BilledDecodeError{Provider: "anthropic", Err: fmt.Errorf("response exceeded %d bytes (truncated, not retried): %w", maxResponseBytes, err)}
}
return nil, true, &BilledDecodeError{Provider: "anthropic", Err: 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 ""
}
}