textmachine/backend/internal/llm/provider_anthropic.go

282 lines
10 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: the OpenAI-compat layer is insufficient, need
// 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
// (rationale in 03-implementation-notes §3.5).
//
// Wire facts (verified 2026-07-04, platform documentation):
// - 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 (see llm.go invariant).
// - cache write bills ×1.25 (5m) / ×2 (1h); read ×0.1. Min cacheable prefix
// is model-dependent (shorter — the cache is silently not created): 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 — not our cases in Phase 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 may expire between stage calls — chunk
// latencies 63278 s, see 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.
//
// Deprecated: Anthropic was removed from the model stack by the owner's decision (04.07):
// expensive, economically unavailable in the RU circuit (Р5), not validated in eval. Not
// a single provider with kind:"anthropic" is currently declared in models.yaml, so
// BuildClient does not build it. The adapter is deliberately KEPT as a working reference
// of a native (non-OpenAI-compat) adapter — the pattern for the native Gemini adapter of
// Phase 2 (§3.9): explicit cache_control breakpoints, the usage-sum invariant, stop_reason
// mapping, the single retryLoop transport. Do not delete without replacing this reference;
// when Anthropic returns — remove the deprecation and return the provider to the config.
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: current Claude
// models (Sonnet 5, Opus 4.7+) reject temperature/top_p with 400 — style control
// goes through the prompt. The stage's configured temperature stays in the
// request-hash (key determinism) but does not reach this 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 (Phase 0: Anthropic —
// only the SFW editor/escalation roles): ""/"off" pass (without thinking
// configuration), while low/medium/high fail loud — a silently billed stage
// WITHOUT the requested reasoning depth is worse than a config error.
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 — Phase 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 (Phase 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; it is ≥500, so the
// shared retryableStatus map covers it alongside 429/5xx (and a credits-exhausted
// 429 is terminal there too — the same pack-12 discipline as the OpenAI transport).
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
retryable := retryableStatus(resp.StatusCode, data)
e := &HTTPStatusError{Provider: "anthropic", Status: resp.StatusCode, Body: snippet(data)}
if retryable {
e.RetryAfter = parseRetryAfter(resp.Header)
}
return nil, retryable, e
}
var out anthropicResponse
if err := json.Unmarshal(data, &out); err != nil {
// 2xx is already billed by the provider — we type it (see httpllm.go); truncation
// is terminal, a break/garbage — 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 ""
}
}