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:
parent
510c1609f9
commit
7fe0a5bdee
9 changed files with 652 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
73
backend/internal/config/models_catalog_test.go
Normal file
73
backend/internal/config/models_catalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
239
backend/internal/llm/pack12_transport_test.go
Normal file
239
backend/internal/llm/pack12_transport_test.go
Normal 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) 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:<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
|
||||
}
|
||||
|
|
|
|||
123
docs/archive/reports/TRANSPORT_PACK12_REPORT_2026-07-24.md
Normal file
123
docs/archive/reports/TRANSPORT_PACK12_REPORT_2026-07-24.md
Normal 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). Кап латентного 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:<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).
|
||||
Loading…
Add table
Reference in a new issue