282 lines
10 KiB
Go
282 lines
10 KiB
Go
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 63–278 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 ""
|
||
}
|
||
}
|