269 lines
9.9 KiB
Go
269 lines
9.9 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: 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"`
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
}
|