Land pack-12 transport hygiene: quota-429 terminal, fractional Retry-After, HTTP/2 keepalive, proportional jitter, xAI identity guard, drift-guard pins, billed-decode cap, models.yaml validation; wire-neutral

This commit is contained in:
Claude (backend session) 2026-07-24 03:18:32 +03:00
parent 510c1609f9
commit 7fe0a5bdee
9 changed files with 652 additions and 26 deletions

View file

@ -3,6 +3,7 @@ module textmachine/backend
go 1.26.4
require (
golang.org/x/net v0.26.0
golang.org/x/text v0.38.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.53.0

View file

@ -14,6 +14,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View file

@ -0,0 +1,73 @@
package config
import (
"testing"
)
// models_catalog_test.go — the pack-12 point-1 catalog validation test (research/21
// §5.3, goose declarative.rs:396-467 `all_bundled_providers_are_valid`): a $0 go-test
// over the SHIPPED configs/models.yaml that fails on catalog drift BEFORE a paid run.
// LoadModels already fail-fasts most of this at load; the test PINS that the shipped
// catalog currently passes (a stale prices_checked, an undeclared provider, a bad
// reasoning/capability enum, a re-armed echo mine all turn this red), and adds the two
// structural invariants LoadModels does not itself assert: every model-referenced
// non-local provider declares an api_key_env, and every resolved min_max_tokens floor
// is in a sane band.
const shippedModelsYAML = "../../configs/models.yaml"
// maxReasonableMinTokens bounds a per-model max_tokens floor. The real floors are
// 8000 (DeepSeek/Gemini) and 16000 (Kimi); a value above this band is almost
// certainly a typo (a token count written as a price, an extra zero) that would
// over-reserve every call on that model.
const maxReasonableMinTokens = 200000
func TestShippedModelsCatalogValid(t *testing.T) {
m, err := LoadModels(shippedModelsYAML)
if err != nil {
// LoadModels aggregates ALL problems into one error — surface it verbatim so
// the operator fixes the drift (stale prices, undeclared provider, bad enum,
// echo mine) before spending a dollar.
t.Fatalf("shipped configs/models.yaml failed validation (catalog drift before a paid run):\n%v", err)
}
// default_model resolves (price-fallback anchor).
if _, ok := m.Models[m.DefaultModel]; !ok {
t.Fatalf("default_model %q is not a defined model", m.DefaultModel)
}
knownReasoning := map[string]bool{"": true, "subset": true, "additive": true, "additive_total": true}
for name, prov := range m.Providers {
switch prov.Kind {
case "openai", "anthropic", "local":
default:
t.Errorf("provider %s: unknown kind %q", name, prov.Kind)
}
if prov.Kind == "openai" && !knownReasoning[prov.Reasoning] {
t.Errorf("provider %s: reasoning %q not in {subset,additive,additive_total}", name, prov.Reasoning)
}
}
for name, mod := range m.Models {
prov, ok := m.Providers[mod.Provider]
if !ok {
t.Errorf("model %s references undeclared provider %q", name, mod.Provider)
continue
}
// Every model-reachable NON-LOCAL provider must declare an api_key_env: a paid
// model with no key env would surface as a 401 only after reserve/slot charge.
if prov.Kind != "local" && prov.APIKeyEnv == "" {
t.Errorf("model %s: non-local provider %s declares no api_key_env", name, mod.Provider)
}
// Non-local models must carry non-zero input/output prices (an unknown model
// must never book at $0 and blind the ceiling).
if prov.Kind != "local" && (mod.Price.InputPerM <= 0 || mod.Price.OutputPerM <= 0) {
t.Errorf("model %s: non-local model needs input/output prices > 0 (got in=%.4f out=%.4f)", name, mod.Price.InputPerM, mod.Price.OutputPerM)
}
// The RESOLVED max_tokens floor (provider→model layering) is sane: never
// negative, never absurdly large.
if floor := m.MinMaxTokens(name); floor < 0 || floor > maxReasonableMinTokens {
t.Errorf("model %s: resolved min_max_tokens %d is out of the sane band [0,%d]", name, floor, maxReasonableMinTokens)
}
}
}

View file

@ -11,8 +11,11 @@ import (
"math/rand"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/net/http2"
"textmachine/backend/internal/obs"
)
@ -65,14 +68,48 @@ func (p RetryProfile) withDefaults() RetryProfile {
return p
}
// h2ReadIdleTimeout / h2PingTimeout tune the cloud transport's HTTP/2 keepalive
// (research/21 §1.5, modelled on the official grok-build's 15s/5s): on a long
// thinking-heavy editor call NO body byte flows until the whole JSON is ready, so a
// proxy/LB can idle-close the socket MID-generation. An HTTP/2 PING keeps the
// connection alive independently of DATA frames; a dead connection then surfaces as
// a clean retryable transport error instead of a mid-generation RST that re-bills the
// call. These are transport keepalive frames only — they never touch the request
// body, so the wire and the job snapshot are unchanged (pack invariant: zero wire bytes).
const (
h2ReadIdleTimeout = 15 * time.Second
h2PingTimeout = 5 * time.Second
)
// keepAliveHTTPClient builds the cloud transport with HTTP/2 PING keepalive. It
// clones http.DefaultTransport (proxy-from-env, dial/TLS timeouts, ForceAttemptHTTP2)
// and layers tuned h2 ping settings via http2.ConfigureTransports — so an HTTPS
// provider gets keepalive while a plaintext test server (httptest, no ALPN) still
// speaks HTTP/1.1 over the same base transport, unchanged. On the (unexpected)
// ConfigureTransports error the plain cloned transport is used as-is: keepalive is a
// reliability bonus, never a hard dependency. The local provider passes its OWN
// no-proxy client (httpc != nil), so it is untouched — localhost needs no h2 keepalive.
func keepAliveHTTPClient() *http.Client {
base := http.DefaultTransport.(*http.Transport).Clone()
if h2, err := http2.ConfigureTransports(base); err == nil && h2 != nil {
h2.ReadIdleTimeout = h2ReadIdleTimeout
h2.PingTimeout = h2PingTimeout
}
return &http.Client{Transport: base}
}
// 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.
// over our backoff schedule, but the HINT is capped to this — a stage holding a
// USD reservation must stay interruptible-by-timeout, not wedged for hours. The
// realized sleep may exceed this by the de-sync jitter (≤+25%, nextBackoff): jitter
// is added AFTER the cap ON PURPOSE, else N parallel waves that all see the same
// Retry-After≥5min would collapse to the identical instant — the thundering herd the
// jitter exists to break. The wait stays ctx-interruptible either way.
const maxRetryAfterWait = 5 * time.Minute
// retryLoop is the ONE retry engine shared by every transport (OpenAI-compat
@ -139,9 +176,31 @@ func nextBackoff(profile RetryProfile, att int, lastErr error) time.Duration {
if ra > maxRetryAfterWait {
ra = maxRetryAfterWait
}
backoff = ra
// Honour the server's hint as a FLOOR and de-sync parallel waves with
// POSITIVE-only proportional jitter (up to +25%): N calls that hit the same
// 429 must not all retry at the same instant (thundering herd), but we never
// retry BEFORE the provider said to.
return ra + proportionalJitter(ra, false)
}
return backoff + time.Duration(rand.Intn(250))*time.Millisecond
// Exponential backoff: symmetric ±25% proportional jitter (research/21 §1.18а)
// de-syncs parallel waves far better than the old fixed 0250ms window, which
// barely moved a 30s backoff.
return backoff + proportionalJitter(backoff, true)
}
// proportionalJitter returns a jitter offset for a backoff of duration d. symmetric
// spreads it over [25%, +25%] (mean-preserving, for exponential backoff); otherwise
// over [0, +25%] (for an honoured Retry-After, which must never be shortened). d ≤ 0
// yields 0.
func proportionalJitter(d time.Duration, symmetric bool) time.Duration {
spread := int64(d) / 4 // 25%
if spread <= 0 {
return 0
}
if symmetric {
return time.Duration(rand.Int63n(2*spread+1) - spread)
}
return time.Duration(rand.Int63n(spread + 1))
}
// openAIClient performs OpenAI-compatible /chat/completions calls with retry.
@ -160,7 +219,7 @@ type openAIClient struct {
// proxy gotcha: env-proxy intercepts non-loopback local addresses like 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{}
httpc = keepAliveHTTPClient()
}
return &openAIClient{
name: name,
@ -290,8 +349,25 @@ func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*op
if err != nil {
return nil, err
}
billedDecodeSeen := 0
return retryLoop(ctx, c.profile, c.name, c.log, func() (*openAIResponse, bool, error) {
return c.attempt(ctx, payload)
resp, retryable, err := c.attempt(ctx, payload)
// Cap billed-decode re-bills at ONE (research/21 §1.18в): an undecodable 2xx
// has ALREADY billed, so re-running it under the full MaxAttempts turns a
// provider emitting 2xx garbage into a paid retry STORM (grok-build's
// "laundering a serialization error into retryable gave a full-budget storm").
// The first occurrence retries once (a transient proxy break is worth one shot);
// a second billed-decode is terminal, so the runner settles at the estimate.
if err != nil && retryable {
var bde *BilledDecodeError
if errors.As(err, &bde) {
billedDecodeSeen++
if billedDecodeSeen > 1 {
return resp, false, err
}
}
}
return resp, retryable, err
})
}
@ -338,11 +414,13 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp
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)}
retryable := retryableStatus(resp.StatusCode, data)
e := &HTTPStatusError{Provider: c.name, Status: resp.StatusCode, Body: snippet(data)}
if retryable {
e.RetryAfter = parseRetryAfter(resp.Header) // only a retryable status will honour it
}
return nil, retryable, e
}
var out openAIResponse
@ -364,6 +442,36 @@ func (c *openAIClient) attempt(ctx context.Context, payload []byte) (*openAIResp
return &out, false, nil
}
// retryableStatus is the ONE source of the retry/terminal split for a non-2xx
// response — pinned by a drift-guard test (research/21 §1.18б) so a new status
// class can't silently change retry policy. Only 429 and 5xx are transient; every
// other non-2xx is a terminal config/request error that must fail loud. The one
// nuance is a CREDITS-EXHAUSTED 429: a body that marks the account/quota as spent
// is terminal, not transient — retrying under a held USD reservation only burns
// wall-clock (opencode/goose both classify quota/credits as fatal). A 402
// (Payment Required) is already terminal by falling through to the default.
func retryableStatus(status int, body []byte) bool {
switch {
case status == http.StatusTooManyRequests:
return !isQuotaExhausted(body) // plain rate-limit → retry; credits gone → terminal
case status >= 500:
return true
default:
return false // terminal 4xx (incl. 402 Payment Required)
}
}
// isQuotaExhausted reports whether a 429 body marks the account's credits/quota as
// spent — a NARROW, high-confidence marker set (research/21 §1.4), deliberately NOT
// a broad regex battery (the aider-style «exact matchers dead» brittleness this pack
// explicitly avoids). `insufficient_quota` is the OpenAI-family error code;
// `quota_exceeded` covers the others. Retrying either never succeeds — the operator
// must top up — so the wave fails loud in one attempt instead of 3 + honoured-backoff.
func isQuotaExhausted(body []byte) bool {
b := strings.ToLower(string(body))
return strings.Contains(b, "insufficient_quota") || strings.Contains(b, "quota_exceeded")
}
// 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,
@ -423,12 +531,30 @@ func jsonResponseFormat(jsonOnly bool) any {
return nil
}
func parseRetryAfter(v string) time.Duration {
// parseRetryAfter reads a provider's honoured backoff hint from the response
// headers (research/21 §1.13). It accepts, in priority order:
// - Retry-After-Ms (millisecond precision; opencode/openai-go/anthropic-sdk-go
// all read it FIRST — some providers only send this);
// - Retry-After as FRACTIONAL or integer seconds (the old strconv.Atoi rejected
// "1.5"/"0.5" → 0 → blind exponential backoff, ignoring a sub-second hint);
// - Retry-After as an HTTP-date.
//
// The value is still bounded by maxRetryAfterWait in nextBackoff — this only widens
// what we can PARSE, never how long we will actually wait.
func parseRetryAfter(h http.Header) time.Duration {
if ms := strings.TrimSpace(h.Get("Retry-After-Ms")); ms != "" {
if n, err := strconv.ParseFloat(ms, 64); err == nil && n > 0 {
return time.Duration(n * float64(time.Millisecond))
}
}
v := strings.TrimSpace(h.Get("Retry-After"))
if v == "" {
return 0
}
if secs, err := strconv.Atoi(v); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
// Fractional or integer seconds. ParseFloat("120") is 120, so this also covers
// the integer form the old Atoi handled.
if secs, err := strconv.ParseFloat(v, 64); err == nil && secs > 0 {
return time.Duration(secs * float64(time.Second))
}
if t, err := http.ParseTime(v); err == nil {
if d := time.Until(t); d > 0 {

View file

@ -296,13 +296,17 @@ func TestRetryLoopLogsWillRetryOnlyWhileAttemptsRemain(t *testing.T) {
}
}
func TestBilledDecodeErrorOn2xxGarbage(t *testing.T) {
// 2xx with an unreadable body: it is retried, and on exhaustion returns a typed
// billed error — the runner uses it to settle the estimate instead of returning the reserve.
// TestBilledDecodeErrorReBillCap pins the pack-12 point-9 cap (research/21 §1.18в): a
// 2xx with an unreadable body has ALREADY billed, so it is retried AT MOST ONCE — a
// second billed-decode is terminal, never the full MaxAttempts. Before the cap a
// provider emitting persistent 2xx garbage burned all 3 attempts (a paid retry storm);
// now the runner receives the typed BilledDecodeError after 2 calls and settles the
// estimate. The one retry is preserved so a transient proxy break still gets a second shot.
func TestBilledDecodeErrorReBillCap(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Write([]byte(`{"truncated`)) // body truncated by a proxy
w.Write([]byte(`{"truncated`)) // body truncated by a proxy — undecodable, but billed
}))
defer srv.Close()
@ -312,7 +316,7 @@ func TestBilledDecodeErrorOn2xxGarbage(t *testing.T) {
if err == nil || !errors.As(err, &bde) {
t.Fatalf("want BilledDecodeError, got %v", err)
}
if calls.Load() != 3 {
t.Fatalf("garbled 2xx must be retried to exhaustion, calls=%d", calls.Load())
if calls.Load() != 2 { // fastProfile MaxAttempts=3, but the billed-decode cap stops at 2
t.Fatalf("a billed 2xx-garbage must be re-billed at most once (2 calls), got %d", calls.Load())
}
}

View file

@ -0,0 +1,239 @@
package llm
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// pack12_transport_test.go — drift-guards and behaviour pins for the research/21
// transport-hygiene pack (points 2, 3, 5, 6, 8a). Each is wire-neutral: none asserts
// on the request BODY, only on retry/terminal classification, backoff parsing, usage
// accounting and jitter bounds.
// TestRetryableStatusMap pins the ENTIRE status→retryable map (point 8а): a new status
// class cannot silently change retry policy without failing this test. 429 and 5xx are
// transient; a credits-exhausted 429 body is terminal; every other non-2xx is terminal.
func TestRetryableStatusMap(t *testing.T) {
plain := []byte(`{"error":{"message":"slow down"}}`)
quota := []byte(`{"error":{"code":"insufficient_quota","message":"you exceeded your current quota"}}`)
cases := []struct {
status int
body []byte
want bool
}{
{http.StatusBadRequest, plain, false}, // 400
{http.StatusUnauthorized, plain, false}, // 401
{http.StatusPaymentRequired, plain, false}, // 402 — credits, terminal
{http.StatusForbidden, plain, false}, // 403
{http.StatusNotFound, plain, false}, // 404
{http.StatusRequestTimeout, plain, false}, // 408 — we do NOT retry (codex-style avoided)
{http.StatusConflict, plain, false}, // 409
{http.StatusUnprocessableEntity, plain, false}, // 422 (Mistral extra_forbidden class)
{http.StatusTooManyRequests, plain, true}, // 429 plain → retry
{http.StatusTooManyRequests, quota, false}, // 429 credits exhausted → terminal
{http.StatusInternalServerError, plain, true}, // 500
{http.StatusBadGateway, plain, true}, // 502
{http.StatusServiceUnavailable, plain, true}, // 503
{529, plain, true}, // Anthropic overloaded (≥500)
}
for _, c := range cases {
if got := retryableStatus(c.status, c.body); got != c.want {
t.Errorf("retryableStatus(%d) = %v, want %v", c.status, got, c.want)
}
}
}
// TestQuota429IsTerminal (point 2): a 429 whose body marks credits exhausted fails loud
// in ONE attempt (no 3× retries under a held USD reservation), while a plain 429 still
// retries. Both narrow markers are exercised.
func TestQuota429IsTerminal(t *testing.T) {
for _, marker := range []string{"insufficient_quota", "quota_exceeded"} {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":{"code":"` + marker + `","message":"credits gone"}}`))
}))
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
var se *HTTPStatusError
if !errors.As(err, &se) || se.Status != http.StatusTooManyRequests {
t.Fatalf("%s: want typed 429, got %v", marker, err)
}
if calls.Load() != 1 {
t.Fatalf("%s: a credits-exhausted 429 must be terminal (1 call), got %d", marker, calls.Load())
}
srv.Close()
}
// A plain 429 (no credits marker) still retries to exhaustion.
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":{"message":"rate limited, slow down"}}`))
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
_, _ = c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if calls.Load() != 3 {
t.Fatalf("a plain 429 must stay retryable (3 attempts), got %d", calls.Load())
}
}
// TestParseRetryAfterFractionalAndMs (point 3): Retry-After-Ms wins; fractional and
// integer seconds parse; an HTTP-date parses; anything else is 0. The old strconv.Atoi
// dropped "1.5"/ms → 0 → blind backoff.
func TestParseRetryAfterFractionalAndMs(t *testing.T) {
mk := func(kv ...string) http.Header {
h := http.Header{}
for i := 0; i+1 < len(kv); i += 2 {
h.Set(kv[i], kv[i+1])
}
return h
}
cases := []struct {
name string
h http.Header
want time.Duration
}{
{"ms wins over seconds", mk("Retry-After-Ms", "1500", "Retry-After", "9"), 1500 * time.Millisecond},
{"fractional ms", mk("Retry-After-Ms", "250.5"), 250500 * time.Microsecond},
{"fractional seconds", mk("Retry-After", "1.5"), 1500 * time.Millisecond},
{"integer seconds", mk("Retry-After", "3"), 3 * time.Second},
{"empty", mk(), 0},
{"garbage", mk("Retry-After", "soon"), 0},
{"zero ms ignored, falls to seconds", mk("Retry-After-Ms", "0", "Retry-After", "2"), 2 * time.Second},
}
for _, c := range cases {
if got := parseRetryAfter(c.h); got != c.want {
t.Errorf("%s: parseRetryAfter = %v, want %v", c.name, got, c.want)
}
}
// HTTP-date form resolves to a positive duration near the delta.
future := time.Now().Add(4 * time.Second).UTC().Format(http.TimeFormat)
if d := parseRetryAfter(mk("Retry-After", future)); d <= 0 || d > 5*time.Second {
t.Errorf("HTTP-date Retry-After = %v, want ~4s", d)
}
}
// TestAdditiveReasoningIdentityGuard (point 6): xAI additive reasoning is surfaced only
// when the identity total==prompt+completion+reasoning holds (or total is unreported);
// a broken identity means reasoning is already inside completion → treat as subset (0)
// and WARN, so the ledger never double-bills the (latent) 3044% overcount.
func TestAdditiveReasoningIdentityGuard(t *testing.T) {
serve := func(body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { openAIOK(t, w, body) }))
}
call := func(srv *httptest.Server, logger *slog.Logger) *LLMResponse {
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "xai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditive}, logger)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
return resp
}
// Identity holds: total=100 == prompt10 + completion40 + reasoning50 → surface 50.
srv := serve(`{"id":"a","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":40,"total_tokens":100,"completion_tokens_details":{"reasoning_tokens":50}}}`)
if r := call(srv, nil); r.Usage.ReasoningTokens != 50 {
t.Fatalf("identity holds → reasoning must be 50, got %d", r.Usage.ReasoningTokens)
}
srv.Close()
// Identity BROKEN: total=60 ≠ 10+40+50=100 → reasoning already in completion → 0 + WARN.
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
srv = serve(`{"id":"b","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":40,"total_tokens":60,"completion_tokens_details":{"reasoning_tokens":50}}}`)
if r := call(srv, logger); r.Usage.ReasoningTokens != 0 {
t.Fatalf("broken identity → reasoning must be 0 (subset guard), got %d", r.Usage.ReasoningTokens)
}
srv.Close()
if !strings.Contains(buf.String(), "additive-reasoning identity broken") {
t.Fatalf("a broken identity must WARN as a quirk candidate, logs:\n%s", buf.String())
}
// Total unreported (0): unverifiable → keep the known xAI additive default.
srv = serve(`{"id":"c","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":40,"completion_tokens_details":{"reasoning_tokens":50}}}`)
if r := call(srv, nil); r.Usage.ReasoningTokens != 50 {
t.Fatalf("total unreported → keep additive default 50, got %d", r.Usage.ReasoningTokens)
}
srv.Close()
}
// TestNormalizeOpenAIFinish pins point 8b (composite finish) AND the vendor-checked GLM
// mapping at the adapter boundary (docs.z.ai verified 2026-07-24). The Gemini composite
// `content_filter: PROHIBITED_CONTENT` and GLM's `sensitive` both normalize to the neutral
// content_filter, so the downstream classifier's exact match still fires — exact matchers
// are dead on those wires (D22.8а / research/21 §1.18б). Every other value — `network_error`,
// `model_context_window_exceeded`, an unknown, a lookalike without the colon — stays RAW so
// it remains FAIL-LOUD (never a silently-mapped clean stop, the litellm unknown→stop trap).
func TestNormalizeOpenAIFinish(t *testing.T) {
cases := map[string]string{
"content_filter: PROHIBITED_CONTENT": FinishContentFilter, // Gemini composite (point 8b)
"content_filter:SAFETY": FinishContentFilter, // composite, no space
"sensitive": FinishContentFilter, // GLM content filter (vendor-check)
"content_filter": FinishContentFilter, // bare, unchanged
"content_filtered_ok": "content_filtered_ok", // lookalike WITHOUT the colon → raw
"network_error": "network_error", // raw — fail-loud, NOT stop
"model_context_window_exceeded": "model_context_window_exceeded", // raw (deferred GLM-overflow item)
"stop": "stop",
"length": "length",
"": "",
}
for in, want := range cases {
if got := normalizeOpenAIFinish(in); got != want {
t.Errorf("normalizeOpenAIFinish(%q) = %q, want %q", in, got, want)
}
}
// End-to-end: a Gemini composite content_filter completion surfaces as neutral
// content_filter through the adapter.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
openAIOK(t, w, `{"id":"g","choices":[{"message":{"content":"x"},"finish_reason":"content_filter: PROHIBITED_CONTENT"}],
"usage":{"prompt_tokens":10,"completion_tokens":1}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "gemini", BaseURL: srv.URL, Profile: fastProfile()}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
if resp.FinishReason != FinishContentFilter {
t.Fatalf("Gemini composite content_filter must surface as neutral content_filter, got %q", resp.FinishReason)
}
}
// TestProportionalJitterBounds (point 5): symmetric jitter stays within ±25% and never
// drives a wait non-positive; positive-only jitter (for an honoured Retry-After) never
// shortens the wait below the server hint.
func TestProportionalJitterBounds(t *testing.T) {
d := 20 * time.Second
for i := 0; i < 2000; i++ {
sym := proportionalJitter(d, true)
if sym < -d/4 || sym > d/4 {
t.Fatalf("symmetric jitter %v out of ±25%% of %v", sym, d)
}
if d+sym <= 0 {
t.Fatalf("jitter drove the wait non-positive: %v", d+sym)
}
pos := proportionalJitter(d, false)
if pos < 0 || pos > d/4 {
t.Fatalf("positive-only jitter %v out of [0,25%%] of %v", pos, d)
}
}
if got := proportionalJitter(0, true); got != 0 {
t.Fatalf("zero backoff must yield zero jitter, got %v", got)
}
}

View file

@ -220,12 +220,16 @@ func (c *anthropicClient) attempt(ctx context.Context, payload []byte) (*LLMResp
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"))}
}
// 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 {
return nil, false, &HTTPStatusError{Provider: "anthropic", Status: resp.StatusCode, Body: snippet(data)}
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

View file

@ -4,6 +4,7 @@ import (
"context"
"log/slog"
"net/http"
"strings"
)
// provider_openai.go is the generic adapter for every OpenAI-compatible cloud
@ -77,6 +78,33 @@ func NewOpenAICompatClient(cfg OpenAICompatConfig, logger *slog.Logger) LLMClien
}
}
// additiveReasoning applies the xAI additive-reasoning identity guard (research/21
// §1.9). xAI Chat Completions reports reasoning ADDITIVELY — total == prompt +
// completion + reasoning — and dropping it undercounted Grok spend 3044% in vojo, so
// when that identity holds we surface reasoning as billed-on-top. But if the identity
// is BROKEN (total is reported and ≠ the sum), reasoning is already folded INTO
// completion_tokens (subset semantics) and adding it again would DOUBLE-BILL (the ~3044%
// overcount, now latent) — so we treat it as subset (0) and WARN. The WARN is a
// wire-drift signal and a candidate for 00-provider-quirks (rule of two directions,
// 10.07: a live grok-usage probe decides, we do not silently absorb). When total is not
// reported (0) the identity is unverifiable, so we keep the known-correct xAI additive
// default rather than blind the ledger.
func additiveReasoning(ctx context.Context, log *slog.Logger, provider string, u openAIUsage) int {
rt := u.CompletionTokensDetails.ReasoningTokens
if rt <= 0 {
return 0
}
if u.TotalTokens == 0 || u.TotalTokens == u.PromptTokens+u.CompletionTokens+rt {
return rt // identity holds, or total unreported → keep the additive default
}
if log != nil {
log.WarnContext(ctx, "additive-reasoning identity broken; counting reasoning as subset (double-count guard) — candidate quirk, vendor-check per rule of two directions",
"provider", provider, "prompt", u.PromptTokens, "completion", u.CompletionTokens,
"reasoning", rt, "total", u.TotalTokens)
}
return 0
}
func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
resp, err := c.http.complete(ctx, openAIRequest{
model: req.Model,
@ -99,7 +127,7 @@ func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLM
}
switch c.reasoning {
case ReasoningAdditive:
usage.ReasoningTokens = resp.Usage.CompletionTokensDetails.ReasoningTokens
usage.ReasoningTokens = additiveReasoning(ctx, c.http.log, c.http.name, resp.Usage)
case ReasoningAdditiveTotal:
// Gemini: thinking is only in total_tokens (not completion, no reasoning field).
// Guard >0 so a spec provider whose total == prompt+completion derives 0, not a
@ -116,7 +144,33 @@ func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLM
Text: resp.Text(),
Usage: usage,
Model: model,
FinishReason: resp.FinishReason(),
FinishReason: normalizeOpenAIFinish(resp.FinishReason()),
ProviderRequestID: resp.ID,
}, nil
}
// normalizeOpenAIFinish maps a KNOWN non-standard OpenAI-compat finish_reason onto the
// neutral vocabulary — the wire→neutral mapping that belongs at the adapter boundary
// (like mapAnthropicStopReason), so the downstream classifier keeps its exact match and
// stays vendor-free. Two known non-1:1 cases (research/21 §1.18б, pack-12 points 8b + the
// GLM vendor-check):
//
// - GLM/z.ai `sensitive` is its content filter → neutral content_filter (docs.z.ai
// chat-completion reference — stop | tool_calls | length | sensitive |
// model_context_window_exceeded | network_error; vendor-checked 2026-07-24, rule of
// two directions). Our editor IS GLM, so a filtered edit must read as a filter.
// - Gemini via the OpenAI-compat layer emits a COMPOSITE `content_filter:
// PROHIBITED_CONTENT` (D22.8а) — collapse the `content_filter:<detail>` composite onto
// the neutral token so the exact matcher fires; exact matchers are dead on that wire.
//
// Only the content_filter composite is collapsed. EVERY other value — `network_error`,
// `model_context_window_exceeded`, any other composite, any unknown — passes through RAW so
// it stays FAIL-LOUD (it flows to the content/coverage gates, never a silently-mapped clean
// stop, the litellm unknown→stop trap the strict finish=stop-only contract rejects). Spec
// providers' values already map 1:1, so this is a no-op for them.
func normalizeOpenAIFinish(finish string) string {
if finish == "sensitive" || strings.HasPrefix(finish, FinishContentFilter+":") {
return FinishContentFilter
}
return finish
}

View file

@ -0,0 +1,123 @@
# Отчёт бэкенд-сессии: ПАК-12 «транспорт-гигиена» (по research/21)
**Дата:** 2026-07-24. **Роль:** бэкенд. **Промт:** `docs/BACKEND_TRANSPORT_PACK_SESSION_PROMPT.md` (обновлён 99ec074 — перекройка границы 23.07). **Сессия НЕ коммитит — лендит оркестратор (пак-12 лендится ПЕРВЫМ).**
## Итог одной строкой
Реализованы все пункты пака-12 в моей зоне (`internal/llm/**` + новый валидация-тест в `internal/config/`): точечные, wire-нейтральные добавки транспорта. **Инвариант «ноль wire-байтов» соблюдён** (ни одна правка не трогает тело запроса → snapshot не двигается, `--resnapshot` не требуется). Зона зелёная: `go build/vet` + `go test ./internal/llm/... ./internal/config/... -race` — OK. Пункты 7 и 10 **переданы паку-13** (перекройка границы), мои out-of-zone правки по ним **откачены** (чужие файлы не тронуты). Пункт 8b **перенесён в llm-адаптер** (зона `disposition.go` теперь пака-13).
## Дельта скоупа (перекройка границы 23.07)
| Пункт | Статус | Зона |
|---|---|---|
| 1 валидация-тест models.yaml | ✅ сделано | `internal/config/` |
| 2 quota-429→terminal | ✅ | `internal/llm/httpllm.go` |
| 3 parseRetryAfter дробн.+ms | ✅ | `httpllm.go` |
| 4 HTTP/2 keepalive + гейт-проба | ✅ | `httpllm.go` + `go.mod` |
| 5 пропорциональный джиттер | ✅ | `httpllm.go` |
| 6 xAI reasoning identity-гард | ✅ | `provider_openai.go` |
| **7 CostSource-маркер** | **ПЕРЕДАН паку-13** — правки откачены | (была pipeline/store/tmctl) |
| 8a пин status→retryable-мапы | ✅ | `httpllm.go` |
| 8b композит-finish классификация | ✅ **перенесён в llm-адаптер** | `provider_openai.go` (не `disposition.go`) |
| 9 кап billed-decode-ре-биллов | ✅ | `httpllm.go` |
| **10 сегмент-луп-гард** | **ПЕРЕДАН паку-13** — правки откачены | (была pipeline/quality) |
| вендор-сверка (a) GLM finish | ✅ подтверждено → реализовано | `provider_openai.go` |
| вендор-сверка (b) kimi temp | ✅ подтверждено «force:1 валиден» → без изменений | report-only |
### Про откат пунктов 7/10 (координация)
Оркестратор передал пункты 7 и 10 паку-13 (они pipeline-зона). Я успел их сделать до перекройки; после — **откатил ТОЛЬКО свои out-of-zone правки** `git checkout HEAD -- <файлы>`, предварительно построчно убедившись, что в этих файлах нет содержимого пака-13 (был только мой код). Файлы пака-13 (`internal/pipeline/{seeding,chunker,mineddelta_collision_test}.go`, `internal/lang/langpack.go`, `configs/langpacks/zh-ru/heading.txt`) **не тронуты**. Полный диф моей реализации 7/10 сохранён в скретчпаде (`pack12_modified.diff`) — доступен паку-13 как референс, если пригодится.
### Про пункт 8b (перенос в llm-зону)
Пункт 8b остаётся за паком-12, но его исходная реализация была в `internal/pipeline/disposition.go` (`classify`), которая теперь зона пака-13. Перенёс в **llm-адаптер**: `normalizeOpenAIFinish` (`provider_openai.go:171`) сворачивает Gemini-композит `content_filter: PROHIBITED_CONTENT` → нейтральный `content_filter` **на границе адаптера** (как `mapAnthropicStopReason`), не трогая пайплайн-классификатор. Downstream-классификатор (exact-match) получает нейтральное значение и классифицирует его — цель пункта 8b (композит должен классифицироваться) достигнута. Незнакомый finish проходит RAW → остаётся fail-loud. **Это чище оригинала** (wire→neutral маппинг там, где ему место).
## Реализация по пунктам (file:line)
### 1. Валидация-тест `models.yaml``internal/config/models_catalog_test.go:25` (`TestShippedModelsCatalogValid`)
Загружает **боевой** `configs/models.yaml` через `LoadModels` и падает на дрейфе каталога ДО платного прогона (аналог goose `all_bundled_providers_are_valid`). Пинит: `LoadModels` проходит (ловит устаревший `prices_checked`, необъявленного провайдера, битый enum, эхо-мину); + инварианты, которые сам загрузчик не проверяет: каждый **не-local** провайдер, на который ссылается модель, объявляет `api_key_env`; reasoning ∈ {subset,additive,additive_total}; цены >0 у не-local; каждая модель ссылается на объявленного провайдера; resolved `min_max_tokens` ∈ [0, 200000]. Итерирует по реальным провайдерам/моделям (не хардкод имён). *(Боевой каталог: `prices_checked: 2026-07-10`, 14 дней — свеж.)*
### 2. Quota-429 → terminal — `httpllm.go:449` (`retryableStatus`) + `httpllm.go:466` (`isQuotaExhausted`)
Узкий body-маркер (`insufficient_quota` / `quota_exceeded`) на 429 → терминальная ошибка (не 3 ретрая под USD-резервацией). НЕ широкая regex-батарея (та самая «exact matchers dead» хрупкость, которую пак избегает). HTTP 402 уже был терминальным (падал в default-ветку). Тест `TestQuota429IsTerminal` (`pack12_transport_test.go:57`): оба маркера → 1 вызов; обычный 429 без маркера → 3 ретрая (retryable).
### 3. `parseRetryAfter`: дробные секунды + `Retry-After-Ms``httpllm.go:540`
Сигнатура сменена `(string)``(http.Header)`; приоритет: `Retry-After-Ms` (float ms) → `Retry-After` (float ИЛИ integer секунды через `ParseFloat`) → HTTP-дата. Старый `strconv.Atoi` резал `1.5`/ms → 0 → слепой exp-backoff. Значение по-прежнему под 5-мин капом (`maxRetryAfterWait` в `nextBackoff`). Оба call-site обновлены (`httpllm.go` + `provider_anthropic.go:230`). Тест `TestParseRetryAfterFractionalAndMs` (`pack12_transport_test.go:95`).
### 4. HTTP/2 keepalive — `httpllm.go:92` (`keepAliveHTTPClient`), константы `httpllm.go:80` (15s/5s)
Клонирует `http.DefaultTransport` (proxy-from-env, dial/TLS-таймауты) + `http2.ConfigureTransports` с `ReadIdleTimeout=15s`/`PingTimeout=5s` (по образу офиц. grok-build). HTTPS-провайдеры получают keepalive; plaintext-httptest (без ALPN) остаётся HTTP/1.1 на том же базовом транспорте. Local-провайдер (`httpc != nil`, no-proxy) **не тронут**. Зависимость `golang.org/x/net@v0.26.0` добавлена (из module-cache; прямая, `go mod tidy` чист).
**Гейт-проба (без платных вызовов):** live-дым-проба на 6 фронтов провайдеров с tuned-транспортом (`ReadIdleTimeout=1s`, idle-hold 3s → PING-фреймы летят, проверка reuse коннекта через httptrace). Результат — **все 6 PING-толерантны** (HTTP/2, коннект переиспользован после idle-PING'ов), fallback на h1 **не нужен ни одному**:
```
api.deepseek.com HTTP/2 code 401/401 reused=true → PING-TOLERANT
api.z.ai HTTP/2 code 301/301 reused=true → PING-TOLERANT
api.x.ai HTTP/2 code 421/421 reused=true → PING-TOLERANT
api.mistral.ai HTTP/2 code 404/404 reused=true → PING-TOLERANT
generativelanguage.googleapis.com HTTP/2 code 404/404 reused=true → PING-TOLERANT
api.moonshot.ai HTTP/2 code 404/404 reused=true → PING-TOLERANT
```
*(Проба — throwaway `_test.go`, реальный сетевой вызов, в дереве НЕ оставлена; копия в скретчпаде `zz_h2probe_test.go.bak`.)*
### 5. Пропорциональный джиттер — `httpllm.go:191` (`proportionalJitter`), вызовы в `nextBackoff:162`
`rand.Intn(250)ms` (фикс) → **±25% пропорционально backoff** (симметрично, mean-preserving) для exp-backoff; для honored-Retry-After — **только положительный [0,+25%]** (де-синк N-∥ волн, но НИКОГДА не ретраим раньше серверной подсказки). Тест `TestProportionalJitterBounds` (`pack12_transport_test.go:221`): границы ±25%, не-отрицательность, положительная-только ветка.
### 6. xAI reasoning identity-гард — `provider_openai.go:92` (`additiveReasoning`), вызов `:130`
Additive-фолд `reasoning_tokens` только при `total == prompt+completion+reasoning`; при `total>0` и нарушении identity → reasoning уже внутри completion (subset) → 0 + **WARN** (кандидат в квирк-реестр, правило двух направлений). При `total==0` (не отдан) — неверифицируемо → держим известный xAI-additive дефолт (не слепим ledger). Кап латентного 3044% overcount. Тест `TestAdditiveReasoningIdentityGuard` (`pack12_transport_test.go:132`): identity-holds→50, broken→0+WARN, total-unreported→50. Существующий `TestAdditiveReasoningSemantics` (total не отдан) — по-прежнему зелёный.
### 8a. Пин status→retryable-мапы — `httpllm.go:449` (`retryableStatus`)
Единая пиннабельная точка решения retry/terminal, использована в `attempt()` (`httpllm.go:414`) И в anthropic-адаптере (`provider_anthropic.go:227`, консистентность; 529 покрыт ≥500). Тест `TestRetryableStatusMap` (`pack12_transport_test.go:24`): пинит 400/401/402/403/404/408/409/422→terminal, 429-plain→retry, 429-quota→terminal, 500/502/503/529→retry.
### 8b. Композит-finish классификация — `provider_openai.go:171` (`normalizeOpenAIFinish`), вызов `:147`
См. «перенос в llm-зону» выше. Сворачивает `content_filter:<detail>` (Gemini-композит) И GLM `sensitive` → нейтральный `content_filter`. Всё прочее (`network_error`, `model_context_window_exceeded`, любой unknown) — RAW → fail-loud (НЕ litellm-стиль unknown→stop). Тест `TestNormalizeOpenAIFinish` (`pack12_transport_test.go:182`) + end-to-end через httptest.
### 9. Кап billed-decode-ре-биллов — `httpllm.go:348-363``complete`)
`BilledDecodeError` ретраится **≤1 раза** (счётчик `billedDecodeSeen` в замыкании ОДНОГО `complete()`-вызова, не разделяем между вызовами): первый — retryable (транзиентный обрыв прокси стоит одной попытки), второй billed-decode → terminal. Платный 2xx-мусор больше не жжёт полный MaxAttempts штормом (grok-build-регрессия «laundering serialization → full-budget storm»). Runner получает типизированный `BilledDecodeError` после 2 вызовов и сеттлит estimate (тип сохранён на терминальном возврате). Тест — обновлён `TestBilledDecodeErrorReBillCap` (`httpllm_test.go:305`): пинит 2 вызова (было 3).
## Вендор-сверка (правило 10.07 — реализация ТОЛЬКО при подтверждении в офиц. доке)
**(a) GLM finish-словарь `sensitive`/`network_error` — ПОДТВЕРЖДЕНО → реализовано.**
Источник: `docs.z.ai/api-reference/llm/chat-completion` (сверено 2026-07-24). Документированные значения `finish_reason`: **`stop | tool_calls | length | sensitive | model_context_window_exceeded | network_error`**. Реализация: `sensitive``content_filter` (наш редактор — GLM; фильтр должен читаться как фильтр, а не clean stop); `network_error`НЕ маппим в stop, оставлен RAW (fail-loud). **Бонус-находка:** `model_context_window_exceeded` тоже документирован как finish_reason — относится к отложенному пункту «GLM context-overflow гард» (в NOT-do списке): **не реализовано**, оставлен RAW; **кандидат для решения оркестратора** по отложенному overflow-гарду.
**Строки-кандидаты для `00-provider-quirks.md` (абсорбирует оркестратор ПОСЛЕ решения):**
> `glm-zai` | finish_reason вне OpenAI-словаря: `sensitive` (контент-фильтр GLM), `network_error` (сетевой обрыв — НЕ маппить в stop), `model_context_window_exceeded` (переполнение контекста). | NEW·vendor-verified | docs.z.ai/api-reference/llm/chat-completion (2026-07-24)
**(b) kimi reasoning-модели: temperature omit vs force:1 — ПОДТВЕРЖДЕНО «force:1 валиден» → БЕЗ изменений.**
Источник: `platform.kimi.ai/docs/guide/migrating-from-openai-to-kimi` (сверено 2026-07-24). Цитата: *«Thinking mode uses a fixed `temperature=1.0`»*, non-thinking — `0.6`, *«Any other value will result in an error. We recommend not explicitly setting the temperature… or following the above requirements.»* Наш `force:1` (для thinking-Kimi) — **документированно-валидный выбор** (одна из двух рекомендованных опций). Omit — эквивалентная альтернатива, но:
1. наш `force:1` НЕ баг (работает для thinking-пути);
2. **смена temperature force:1→omit ДВИГАЕТ WIRE** (поле temperature присутствует→отсутствует) → snapshot сдвиг → `--resnapshot`**нарушает инвариант пака «ноль wire-байтов»**.
Вывод: **capability-твик не делаем** (не подтверждена НЕОБХОДИМОСТЬ + противоречит инварианту пака). Report-only.
## Инвариант «ноль wire-байтов» — верификация
Ни одна правка не трогает сборку тела запроса (`openAIRequest.MarshalJSON` / `Capability.applyToBody` / снапшот-фолд `capability.go`). Изменения касаются: (1) классификации retry/terminal ответа, (2) парсинга backoff-заголовков, (3) джиттера, (4) транспортного keepalive (PING-фреймы, не DATA), (5) учёта usage (reasoning), (6) нормализации finish_reason ответа. Всё — **response-side / transport-layer**, request-payload неизменен → request_hash неизменен → snapshot неизменен.
## Тесты (мандат самопроверки 12.07 — ревью ИСПОЛНЕНИЕМ)
Новые/обновлённые тесты, все зелёные под `-race`:
- `TestShippedModelsCatalogValid` (п.1), `TestQuota429IsTerminal` (п.2), `TestParseRetryAfterFractionalAndMs` (п.3), `TestProportionalJitterBounds` (п.5), `TestAdditiveReasoningIdentityGuard` (п.6), `TestRetryableStatusMap` (п.8a), `TestNormalizeOpenAIFinish` (п.8b), `TestBilledDecodeErrorReBillCap` (п.9, обновлён с 3→2).
## Приёмка
- `go build ./...`**OK** (всё дерево, включая in-flight пака-13, компилируется).
- `go vet ./internal/llm/... ./internal/config/...`**OK**. `go test ./internal/llm/... ./internal/config/... -race`**OK**.
- **Полный `go vet ./...` / `go test ./... -race` СЕЙЧАС падает ТОЛЬКО в `internal/pipeline`** — это in-flight состояние пака-13 (`internal/pipeline/chunker_test.go:27`: `SplitChunks` получил новый параметр `*lang.HeadingRule`, тест не догнан). **Не моя зона, не мой код, не чиню** (гардрейл координации). Финальный полный `./... -race` — за оркестратором на СОВМЕСТНОМ лендинге пака-12 + пака-13.
- **Golden:** по обновлённому промту «golden тебя не касается» (моя зона в golden-путь не входит); инвариант «ноль wire-байтов» держит golden байт-идентичным по построению (request-payload не тронут).
- gofmt: мои файлы чисты. *(`internal/llm/llm.go` числится gofmt-«грязным» — это pre-existing состояние HEAD, мной не тронуто, не правлю.)*
## Адверсариальный селф-ревью (author≠reviewer, ≥3 линзы) — ИСПОЛНЕН
Воркфлоу: 4 линзы (детерминизм/wire-нейтральность · деньги · конкурентность · корректность+тест-адекватность), каждая находка верифицируется НЕЗАВИСИМЫМ скептиком (default REFUTED, high-effort). 6 агентов, 0 ошибок.
**Итог: 0 ПОДТВЕРЖДЁННЫХ дефектов.**
- Линзы **деньги** и **конкурентность** — чисты (пусто): identity-гард не двоит/не недоучитывает; `billedDecodeSeen` — per-`complete()` замыкание (не разделяем, гонки нет); keepalive-клиент concurrency-safe; джиттер не уводит backoff в ≤0 и не ретраит раньше honored-Retry-After.
- Линзы **детерминизм** и **корректность**обе подняли ОДНУ и ту же находку (low): пропорциональный джиттер honored-Retry-After применяется ПОСЛЕ клампа `maxRetryAfterWait`, поэтому реальный сон может превысить 5-мин потолок на ≤+25% (≤75с). **Оба независимых скептика — REFUTED:** (1) пре-диф Retry-After-путь УЖЕ овершутил кламп (был `backoff=ra` + `rand.Intn(250)ms`), диф лишь расширил мягкий овершут 250мс→75с, а не ввёл его; (2) джиттер ПОСЛЕ клампа — намеренный анти-thundering-herd дизайн: кламп ДО джиттера схлопнул бы все N-∥ волны с `Retry-After≥5min` в один инстант = ровно тот herd, против которого джиттер; (3) фактический контракт `maxRetryAfterWait` — «не висеть часами, оставаться ctx-прерываемым», что ограниченный +75с ctx-прерываемый сон полностью удовлетворяет. **Не wire-move, не мис-биллинг, не гонка, не спек-violation.**
**Единственная реальная субстанция** (оба ревьюера сошлись): моя правка сделала комментарий `maxRetryAfterWait` («never for longer than this») чуть неточным (пре-диф овершут ≤250мс, мой ≤75с). **Правка применена** (`httpllm.go:106-113`): комментарий уточнён — кламп бьёт по ХИНТУ, реальный сон может +25% от де-синк-джиттера, добавляемого после клампа НАМЕРЕННО; ctx-прерываем. **Ноль изменений поведения**, тесты зелёные. Поведение оставлено как есть (post-cap джиттер — верный дизайн; кламп-после-джиттера воссоздал бы herd на потолке — что скептик и отметил).
## НЕ делал (явные не-цели, подтверждено)
Рефакторинг транспорта · стриминг · assistant-prefill continuation · native-Gemini судья · GLM cache_control · GLM context-overflow гард (но вендор-находка `model_context_window_exceeded` зафиксирована выше) · дата-фикация каталога · `prompt_cache_key`.
## Заметки оркестратору (координация)
1. **Лендить пак-12 ПЕРВЫМ** (по промту), затем пак-13.
2. **Миграция БД:** пак-12 миграцию НЕ добавляет (пункт-7, который её содержал, ушёл паку-13). Если пак-13 добавляет миграцию для CostSource — версия `v10` свободна.
3. **Совместный полный `./... -race`** — прогнать после лендинга ОБОИХ паков (сейчас блокирован in-flight `SplitChunks` пака-13).
4. **Кандидат-строки для `00-provider-quirks`** (GLM finish-словарь) — см. секцию вендор-сверки; абсорбировать после решения.
5. **Зависимость `golang.org/x/net@v0.26.0`** добавлена в `go.mod`/`go.sum` (нужна для http2-keepalive).