diff --git a/backend/go.mod b/backend/go.mod index ad13b34c..ebcf78da 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 782939f2..f909c8b2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/models_catalog_test.go b/backend/internal/config/models_catalog_test.go new file mode 100644 index 00000000..94bc6ed6 --- /dev/null +++ b/backend/internal/config/models_catalog_test.go @@ -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) + } + } +} diff --git a/backend/internal/llm/httpllm.go b/backend/internal/llm/httpllm.go index 8145c3e6..223bf597 100644 --- a/backend/internal/llm/httpllm.go +++ b/backend/internal/llm/httpllm.go @@ -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 ~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. +// 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 0–250ms 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 { diff --git a/backend/internal/llm/httpllm_test.go b/backend/internal/llm/httpllm_test.go index 1fa001d8..92b08326 100644 --- a/backend/internal/llm/httpllm_test.go +++ b/backend/internal/llm/httpllm_test.go @@ -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()) } } diff --git a/backend/internal/llm/pack12_transport_test.go b/backend/internal/llm/pack12_transport_test.go new file mode 100644 index 00000000..0ab28be5 --- /dev/null +++ b/backend/internal/llm/pack12_transport_test.go @@ -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) 30–44% 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) + } +} diff --git a/backend/internal/llm/provider_anthropic.go b/backend/internal/llm/provider_anthropic.go index d46ce44a..60472af2 100644 --- a/backend/internal/llm/provider_anthropic.go +++ b/backend/internal/llm/provider_anthropic.go @@ -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 diff --git a/backend/internal/llm/provider_openai.go b/backend/internal/llm/provider_openai.go index c30a995f..1072f003 100644 --- a/backend/internal/llm/provider_openai.go +++ b/backend/internal/llm/provider_openai.go @@ -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 30–44% 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 ~30–44% +// 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:` 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 +} diff --git a/docs/archive/reports/TRANSPORT_PACK12_REPORT_2026-07-24.md b/docs/archive/reports/TRANSPORT_PACK12_REPORT_2026-07-24.md new file mode 100644 index 00000000..a4b617a8 --- /dev/null +++ b/docs/archive/reports/TRANSPORT_PACK12_REPORT_2026-07-24.md @@ -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). Кап латентного 30–44% 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:` (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).