420 lines
16 KiB
Go
420 lines
16 KiB
Go
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 дедлайн донора убивал
|
||
// бы реальные чанки 63–278 с и трижды бесплатно их ретраил;
|
||
// - 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 {
|
||
// Полигон намерил 63–278 с на чанк локальной моделью; облачные быстрее,
|
||
// но дефолт обязан переживать длинный чанк. Провайдер-специфика — в
|
||
// 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 ~10–50 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"`
|
||
// TotalTokens is prompt+completion for spec providers, but for Gemini via the
|
||
// OpenAI-compat layer it ALSO carries the mandatory thinking tokens, which do NOT
|
||
// appear in completion_tokens and have no reasoning_tokens field (live-probe
|
||
// 2026-07-10: completion=2, prompt=22, total=847 → 823 hidden thinking). The
|
||
// ReasoningAdditiveTotal adapter derives thinking = total − prompt − completion.
|
||
TotalTokens int `json:"total_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)
|
||
}
|