textmachine/backend/internal/llm/httpllm.go

414 lines
16 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"
"errors"
"fmt"
"io"
"log/slog"
"math/rand"
"net/http"
"strconv"
"time"
"textmachine/backend/internal/obs"
)
// httpllm.go is the shared OpenAI-compatible Chat Completions transport: one
// HTTP+retry implementation reused by every OpenAI-compatible adapter
// (DeepSeek, GLM, Kimi, xAI, ru-агрегаторы, llama-server). Порт vojo
// httpllm.go с правками Фазы 0 (03-implementation-notes §2 п.2):
//
// - все таймауты/ретраи — ПАРАМЕТРЫ клиента из профиля models.yaml, не
// константы: захардкоженный 60-секундный per-attempt дедлайн донора убивал
// бы реальные чанки 63278 с и трижды бесплатно их ретраил;
// - Retry-After на 429 читается и уважается (донор капил backoff на 8 с
// молоток по rate-limit'ам массового прогона глав), но сам капится
// maxRetryAfterWait — иначе враждебный «Retry-After: 7200» вешает стадию
// на часы с удержанным резервом;
// - тело ответа читается через LimitReader (у донора кап был только в
// gemini-native адаптере).
//
// Задокументированное отступление от донора: one-shot self-heal параметра
// reasoning_effort (модель, вернувшая 400, запоминалась и параметр срезался)
// НЕ портирован — у нас reasoning задаётся per-стадия в конфиге с fail-fast
// валидацией, и 400 от провайдера должен чиниться в конфиге, а не глотаться.
// RetryProfile bounds one client's retry loop. Zero fields fall back to
// defaults; the profile comes from models.yaml per provider (per-role
// overrides — Фаза 1).
type RetryProfile struct {
AttemptTimeout time.Duration // deadline for ONE HTTP attempt
MaxAttempts int
BackoffBase time.Duration // first backoff; doubles per attempt
BackoffCap time.Duration
}
func (p RetryProfile) withDefaults() RetryProfile {
if p.AttemptTimeout <= 0 {
// Полигон намерил 63278 с на чанк локальной моделью; облачные быстрее,
// но дефолт обязан переживать длинный чанк. Провайдер-специфика — в
// models.yaml, это только страховочный потолок.
p.AttemptTimeout = 300 * time.Second
}
if p.MaxAttempts <= 0 {
p.MaxAttempts = 3
}
if p.BackoffBase <= 0 {
p.BackoffBase = 500 * time.Millisecond
}
if p.BackoffCap <= 0 {
p.BackoffCap = 30 * time.Second
}
return p
}
// maxResponseBytes caps one completion body read. A translated chapter chunk
// is ~1050 KiB; 16 MiB leaves two orders of magnitude of headroom while
// keeping a misbehaving endpoint from exhausting memory.
const maxResponseBytes = 16 << 20
// maxRetryAfterWait bounds an honoured Retry-After: the provider's hint wins
// over our backoff schedule, but never for longer than this — a stage holding
// a USD reservation must stay interruptible-by-timeout, not wedged for hours.
const maxRetryAfterWait = 5 * time.Minute
// retryLoop is the ONE retry engine shared by every transport (OpenAI-compat
// and the native Anthropic adapter): exponential backoff with cap, Retry-After
// override, jitter, ctx-done select. Один экземпляр политики — чтобы фикс
// ретраев не «уезжал» от одного провайдера, забыв другой.
func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, log *slog.Logger, attempt func() (T, bool, error)) (T, error) {
var zero T
var lastErr error
for att := 0; att < profile.MaxAttempts; att++ {
if att > 0 {
shift := att - 1
if shift > 20 {
shift = 20 // защита сдвига от переполнения при больших max_attempts
}
backoff := profile.BackoffBase << uint(shift)
if backoff <= 0 || backoff > profile.BackoffCap {
backoff = profile.BackoffCap
}
if ra := retryAfterOf(lastErr); ra > 0 {
if ra > maxRetryAfterWait {
ra = maxRetryAfterWait
}
backoff = ra
}
backoff += time.Duration(rand.Intn(250)) * time.Millisecond
select {
case <-ctx.Done():
return zero, ctx.Err()
case <-time.After(backoff):
}
}
resp, retryable, err := attempt()
if err == nil {
return resp, nil
}
lastErr = err
if ctx.Err() != nil {
return zero, ctx.Err()
}
if !retryable {
return zero, err
}
if log != nil {
log.WarnContext(ctx, name+" attempt failed, will retry", "attempt", att+1, "max", profile.MaxAttempts, "err", err)
}
}
return zero, fmt.Errorf("%s: exhausted %d attempts: %w", name, profile.MaxAttempts, lastErr)
}
// openAIClient performs OpenAI-compatible /chat/completions calls with retry.
type openAIClient struct {
name string // provider label for logs/errors ("deepseek", "glm", "local")
base string
key string
http *http.Client
profile RetryProfile
headers map[string]string // extra static headers (provider-specific), may be nil
log *slog.Logger
}
// newOpenAIClient builds the shared transport. httpc may be nil (default
// client); the local provider passes an explicit no-proxy client (прокси-грабли
// стенда: env-прокси перехватывает не-loopback локальные адреса вроде 172.x).
func newOpenAIClient(name, base, key string, profile RetryProfile, headers map[string]string, httpc *http.Client, logger *slog.Logger) *openAIClient {
if httpc == nil {
httpc = &http.Client{}
}
return &openAIClient{
name: name,
base: base,
key: key,
http: httpc,
profile: profile.withDefaults(),
headers: headers,
log: logger,
}
}
// --- OpenAI-compatible wire types -------------------------------------------------
type openAIMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// openAIRequest is the neutral pre-wire request. Its body is assembled by
// MarshalJSON THROUGH the per-model Capability resolver (capability.go), not
// from fixed json tags: OpenAI-compatible providers share an endpoint but not a
// body — the budget key, whether temperature is emitted, and the reasoning
// switch all vary per model (D3.1). The Phase-0 fixed body (max_tokens +
// always-send temperature + reasoning_effort) 400s on Kimi / gpt-5 / Gemini;
// the resolver renders the correct shape and is itself folded into the job
// snapshot, so changing it invalidates checkpoints rather than false-hitting.
type openAIRequest struct {
model string
messages []openAIMessage
maxTokens int
temperature float64
stream bool
// reasoningEffort is the NEUTRAL effort ("" | off | low | medium | high);
// cap decides how — and whether — it reaches the wire.
reasoningEffort string
responseFormat any
cap Capability
// extra carries per-model provider-specific fields from models.yaml. Merged
// LAST and only into gaps — a resolved standard key always wins.
extra map[string]any
}
// MarshalJSON assembles the wire body via the capability resolver. Resolved
// standard keys are authoritative; config-supplied extra_body fills only gaps.
func (r openAIRequest) MarshalJSON() ([]byte, error) {
m := map[string]any{
"model": r.model,
"messages": r.messages,
"stream": r.stream,
}
r.cap.applyToBody(m, r.maxTokens, r.temperature, r.reasoningEffort)
if r.responseFormat != nil {
m["response_format"] = r.responseFormat
}
mergeBody(m, r.extra)
return json.Marshal(m)
}
type openAIUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
PromptTokensDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"prompt_tokens_details"`
// xAI reports reasoning tokens here SEPARATELY from completion_tokens and
// bills them at the output rate; OpenAI-spec providers count them inside
// completion_tokens. The adapter decides which semantics apply.
CompletionTokensDetails struct {
ReasoningTokens int `json:"reasoning_tokens"`
} `json:"completion_tokens_details"`
// DeepSeek historically reports cache usage in its own top-level fields
// instead of prompt_tokens_details. Parse both so the Phase-0 cache
// experiment can't show a false zero (03-implementation-notes §3.5).
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
}
// cacheRead returns the cache-hit token count whichever field the provider
// used.
func (u openAIUsage) cacheRead() int {
if u.PromptTokensDetails.CachedTokens > 0 {
return u.PromptTokensDetails.CachedTokens
}
return u.PromptCacheHitTokens
}
type openAIResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage openAIUsage `json:"usage"`
}
func (r *openAIResponse) Text() string {
if len(r.Choices) == 0 {
return ""
}
return r.Choices[0].Message.Content
}
func (r *openAIResponse) FinishReason() string {
if len(r.Choices) == 0 {
return ""
}
// OpenAI-compat wire values map 1:1 onto the neutral constants.
return r.Choices[0].FinishReason
}
// complete calls Chat Completions with retry on transient failures (429 / 5xx /
// network, exponential backoff + jitter, Retry-After honoured). Non-retryable
// 4xx fail immediately. On exhaustion the caller releases the reservation, so
// a transient failure is never silently swallowed.
func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*openAIResponse, error) {
payload, err := json.Marshal(reqBody)
if err != nil {
return nil, err
}
return retryLoop(ctx, c.profile, c.name, c.log, func() (*openAIResponse, bool, error) {
return c.attempt(ctx, payload)
})
}
// attempt performs one HTTP call. Returns retryable=true for 429/5xx and
// network errors, false for other non-2xx (terminal 4xx). The per-attempt
// deadline bounds a single hung connection; the overall per-request deadline
// (set by the caller via ctx) bounds the whole retry loop.
func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResponse, bool, error) {
attemptCtx, cancel := context.WithTimeout(ctx, c.profile.AttemptTimeout)
defer cancel()
req, err := http.NewRequestWithContext(attemptCtx, http.MethodPost, c.base+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return nil, false, err
}
req.Header.Set("Content-Type", "application/json")
// A local backend (ollama / llama-server) usually runs without auth; an
// empty key means "no Authorization header", not "Bearer " with an empty token.
if c.key != "" {
req.Header.Set("Authorization", "Bearer "+c.key)
}
for k, v := range c.headers {
req.Header.Set(k, v)
}
resp, err := c.http.Do(req)
if err != nil {
// Network error / timeout — retryable (unless the parent ctx is done).
return nil, ctx.Err() == nil, err
}
defer resp.Body.Close()
// Читаем на 1 байт больше лимита, чтобы ОТЛИЧИТЬ усечение от целого тела.
data, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
truncated := len(data) > maxResponseBytes
if truncated {
data = data[:maxResponseBytes]
}
obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data)
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return nil, true, &HTTPStatusError{Provider: c.name, 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: c.name, Status: resp.StatusCode, Body: snippet(data)}
}
var out openAIResponse
if err := json.Unmarshal(data, &out); err != nil {
// 2xx с нечитаемым телом: провайдер УЖЕ списал деньги. Типизируем, чтобы
// раннер консервативно заселтлил оценку. Усечённое (>16 MiB) тело
// детерминированно — ретраить бессмысленно (каждый ретрай — новый
// оплаченный 2xx), поэтому терминально; обрыв/мусор — retryable.
if truncated {
return nil, false, &BilledDecodeError{Provider: c.name, Err: fmt.Errorf("response exceeded %d bytes (truncated, not retried): %w", maxResponseBytes, err)}
}
return nil, true, &BilledDecodeError{Provider: c.name, Err: err}
}
// A 2xx is a billed call even when the model returns empty content
// (content filter, finish_reason=length with no text). Return it as a
// success so the caller settles the real cost via the ledger instead of
// releasing the reservation and losing the spend — which would let empty
// replies bypass the book/day ceilings. Gates deal with the empty text.
return &out, false, nil
}
// HTTPStatusError is a non-2xx completion failure, carrying the status code as
// a typed field so callers (the failover decorator) can classify it
// structurally — a terminal 4xx is a config/request error that must fail loud,
// not be masked by a fallback — instead of parsing the message.
type HTTPStatusError struct {
Provider string
Status int
Body string
RetryAfter time.Duration // from the Retry-After header; 0 = absent
}
func (e *HTTPStatusError) Error() string {
return fmt.Sprintf("%s http %d: %s", e.Provider, e.Status, e.Body)
}
// BilledDecodeError is a 2xx whose body could not be decoded: the provider has
// billed the call, but usage/text are unknown. The runner treats an exhausted
// retry chain ending in this error as BILLED (settle at the reservation
// estimate) rather than releasing the reservation.
type BilledDecodeError struct {
Provider string
Err error
}
func (e *BilledDecodeError) Error() string {
return fmt.Sprintf("%s: 2xx body decode failed (call IS billed): %v", e.Provider, e.Err)
}
func (e *BilledDecodeError) Unwrap() error { return e.Err }
func retryAfterOf(err error) time.Duration {
var se *HTTPStatusError
if errors.As(err, &se) {
return se.RetryAfter
}
return 0
}
// toOpenAIMessages maps neutral messages onto the wire. CacheBoundary is
// meaningless here: OpenAI-compatible caches (DeepSeek: prefix-match с 0-го
// токена блоками по 64 токена) are automatic; the stable-prefix ORDER the
// assembler produced is all that matters.
func toOpenAIMessages(msgs []Message) []openAIMessage {
out := make([]openAIMessage, len(msgs))
for i, m := range msgs {
out[i] = openAIMessage{Role: m.Role, Content: m.Content}
}
return out
}
// jsonResponseFormat returns the response_format value for JSONOnly requests
// (nil otherwise, so the field serializes away).
func jsonResponseFormat(jsonOnly bool) any {
if jsonOnly {
return map[string]string{"type": "json_object"}
}
return nil
}
func parseRetryAfter(v string) time.Duration {
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
if t, err := http.ParseTime(v); err == nil {
if d := time.Until(t); d > 0 {
return d
}
}
return 0
}
func snippet(b []byte) string {
const max = 300
if len(b) > max {
return string(b[:max]) + "…"
}
return string(b)
}