package llm import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "math/rand" "net/http" "net/http/httptrace" "strconv" "strings" "sync" "time" "golang.org/x/net/http2" "textmachine/backend/internal/obs" ) // httpllm.go is the shared OpenAI-compatible Chat Completions transport: one // HTTP+retry implementation reused by every OpenAI-compatible adapter // (DeepSeek, GLM, Kimi, xAI, ru-aggregators, llama-server). A port of vojo's // httpllm.go with Phase-0 edits (03-implementation-notes §2 item 2): // // - all timeouts/retries are client PARAMETERS from the models.yaml profile, // not constants: the donor's hardcoded 60-second per-attempt deadline would // have killed real 63–278 s chunks and retried them three times for free; // - Retry-After on a 429 is read and honoured (the donor capped backoff at 8 s // — a hammer against a bulk chapter run's rate-limits), but is itself capped // by maxRetryAfterWait — otherwise a hostile «Retry-After: 7200» hangs the // stage for hours with a held reservation; // - the response body is read through a LimitReader (the donor capped only in // the gemini-native adapter). // // A documented departure from the donor: the one-shot self-heal of the // reasoning_effort parameter (a model returning 400 was remembered and had the // parameter stripped) is NOT ported — we set reasoning per-stage in config with // fail-fast validation, and a provider's 400 must be fixed in config, not swallowed. // RetryProfile bounds one client's retry loop. Zero fields fall back to // defaults; the profile comes from models.yaml per provider (per-role // overrides — Phase 1). type RetryProfile struct { AttemptTimeout time.Duration // FLOOR for one HTTP attempt's deadline (see deadlineFor) MaxAttempts int BackoffBase time.Duration // first backoff; doubles per attempt BackoffCap time.Duration // The three fields below turn the per-attempt deadline from a constant into a function of the // budget the call carries (attemptcut.go). All three are optional and all three are DATA: a // provider the repository has never seen gets a working deadline from the vendor default and a // startup warning naming what to measure, with no Go edit. // // TokensPerSecFloor is the slowest generation speed this provider has been OBSERVED to hold — // below the p10 of its own request_log, rounded down. Unset ⇒ the vendor default. TokensPerSecFloor float64 // QueueSlack is how long the VENDOR documents a request may wait before generation starts. It is // added whole rather than amortized: the wait is not proportional to the budget. QueueSlack time.Duration // AttemptMax bounds the derived deadline — the longest one call may hold a reservation. 0 = unbounded. AttemptMax time.Duration } func (p RetryProfile) withDefaults() RetryProfile { if p.AttemptTimeout <= 0 { // The eval measured 63–278 s per chunk on a local model; cloud is faster, // but the default must survive a long chunk. Provider specifics live in // models.yaml; this is only a safety ceiling. p.AttemptTimeout = 300 * time.Second } if p.MaxAttempts <= 0 { p.MaxAttempts = 3 } if p.BackoffBase <= 0 { p.BackoffBase = 500 * time.Millisecond } if p.BackoffCap <= 0 { p.BackoffCap = 30 * time.Second } return p } // 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 { c, _ := buildCloudClient() return c } // buildCloudClient is the construction itself, handing back BOTH the client and the h2 transport it // installed the bounds on. The second return is what makes the bounds checkable at all: reading them off // a finished client is impossible (ConfigureTransports answers an error the second time), so a check // written against a client would have to re-derive them on a transport of its own and would then be // asserting about a transport nobody uses. func buildCloudClient() (*http.Client, *http2.Transport) { base := http.DefaultTransport.(*http.Transport).Clone() h2 := tuneHTTP2(base) return &http.Client{Transport: base, CheckRedirect: doNotFollowRedirects}, h2 } // tuneHTTP2 installs the keepalive pair and RETURNS the h2 transport it configured, so a test can read // what was actually set rather than re-deriving it. Asking ConfigureTransports a second time answers an // error and no transport, so a check written against an already-built client asserts nothing at all. // nil on the (unexpected) configure error: keepalive is a reliability bonus, never a hard dependency. func tuneHTTP2(base *http.Transport) *http2.Transport { h2, err := http2.ConfigureTransports(base) if err != nil || h2 == nil { return nil } h2.ReadIdleTimeout = h2ReadIdleTimeout h2.PingTimeout = h2PingTimeout return h2 } // doNotFollowRedirects stops the client at a 3xx instead of chasing it, so the redirect surfaces as the // terminal non-2xx it is and the operator is told the base_url is wrong. // // ⛔ IT IS A MONEY GUARD BEFORE IT IS A HYGIENE ONE. `Do` spans the WHOLE redirect chain, and the // delivery trace does not reset between its legs: the first leg reaching a redirector sets WroteRequest // (and GotFirstResponseByte) for good, so a second leg whose connect is REFUSED still looked delivered. // Measured through the ledger: $0.001056 booked for a `connect: connection refused` that never put a byte // on any wire, with AfterHeaders true and zero bytes from the target. A stale `http://` or a normalised // slash in base_url is enough to trigger it. // // The second reason is the one a security review would raise first: net/http drops Authorization only // across a host change it considers unsafe, and a provider endpoint that redirects is a misconfiguration // in every case — there is no shape in which silently following one is what an operator wanted. func doNotFollowRedirects(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } // 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 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 // and the native Anthropic adapter): exponential backoff with cap, Retry-After // override, jitter, ctx-done select. A single policy instance — so a retry // fix doesn't "drift" to one provider while forgetting another. // // Logs are a product for the operator (package №4, pains cleared by a smoke run): // each attempt's start is visible at DEBUG (otherwise "hung on a timeout" is // indistinguishable from "died"), WARN carries the ACTUAL wait before the next // attempt (Retry-After can silently stretch it to 5 min against the expected // backoff_cap) and says "will retry" ONLY when an attempt actually remains — // previously the last failure too was logged "will retry" before the exhausted error. func retryLoop[T any](ctx context.Context, profile RetryProfile, name string, log *slog.Logger, attempt func() (T, bool, error)) (T, error) { var zero T var lastErr error // ⛔ A DELIVERED CUT MUST OUTLIVE THE ATTEMPT THAT MADE IT. Only the LAST error left this loop, so a // chain that cut a delivered request and then met a terminal 4xx — a 400/401/403/413 on the retry, // entirely reachable — returned the status alone: the runner saw «the request never went out», gave // the reservation back and booked $0 for a generation the provider had made, leaving the position // with no mark either. The first cut is kept and joined onto whatever ends the chain, so the money // and the reason a caller finally reports are both true of one error. var owedCut error for att := 0; att < profile.MaxAttempts; att++ { if log != nil { log.DebugContext(ctx, name+" attempt start", "attempt", att+1, "max", profile.MaxAttempts, "attempt_timeout", profile.AttemptTimeout.String()) } resp, retryable, err := attempt() if err == nil { return resp, nil } lastErr = err owedCut = moreOwed(owedCut, err) if ctx.Err() != nil { return zero, chainError(cancelledDuring(ctx.Err(), err), owedCut) } if !retryable { return zero, chainError(err, owedCut) } if att+1 >= profile.MaxAttempts { break // attempts exhausted — no retry remains, no sleep } backoff := nextBackoff(profile, att, lastErr) if log != nil { log.WarnContext(ctx, name+" attempt failed, will retry", "attempt", att+1, "max", profile.MaxAttempts, "retry_in", backoff.Round(time.Millisecond).String(), "err", err) } select { case <-ctx.Done(): // ⛔ THE SECOND CANCELLATION EXIT, and it used to throw the evidence away where the first one // no longer does. A run stopped DURING a backoff has an attempt behind it that may already // have been delivered and billed — a cut connection, an undecodable 2xx — and returning a // bare ctx.Err() here left the runner with nothing to settle and the chunk with no mark at // all. Measured: a delivered connection_lost plus a stop inside the backoff booked $0 and // wrote no chunk_status row. The window is the whole sleep, up to a minute on the shipping // config, and it opens precisely on the runs where a provider is flapping and an operator is // therefore reaching for the stop. // // ⚠ THIS CLOSES THE MONEY HALF ONLY. What leaves here carries the cut with the strongest // money claim, which is deliberately NOT the stop when an earlier paid break outranks it — so // the runner's mark cannot be decided by this error's first cause. The mark asks its own // question (pipeline's recordCancelledStage: was the run stopped, and did ANY cut deliver), // and a guard that read the first cause instead left the position with no row at all. return zero, chainError(cancelledDuring(ctx.Err(), lastErr), owedCut) case <-time.After(backoff): } } return zero, chainError(fmt.Errorf("%s: exhausted %d attempts: %w", name, profile.MaxAttempts, lastErr), owedCut) } // ⛔ THE ERROR IS A SCALAR AND THE CHAIN IS A LIST, and every defect this pair exists to stop came from // that mismatch. A retry chain can deliver, and be billed for, an attempt that is NOT the attempt whose // error ends it: a cut, then a 503, then a stop. Whatever one error the loop finally returns is the ONLY // thing the caller settles from — so it must carry the cut the chain owes money for, from every exit, // not from the two that happened to be written with it in mind. // // moreOwed is the accumulator, and it keeps the FIRST cut deliberately rather than ranking them here. // // ⚠ RANKING BELONGS AT THE EXIT, and putting it here as well would be a guard that cannot fire. A chain // holds at most TWO cuts: only CutByConnection is retryable (AttemptCutError.retryable), and the // delivered-cut cap makes the second one terminal (`retryable && deliveredCutSeen > 1` → not retryable). // So whenever two cuts exist, the second one IS the error ending the chain and is in `chainError`'s hand // already; whenever only one exists, there is nothing to rank. A billable-beats-free branch here would // read like a money guard and never execute — the shape this pack has spent a shift removing. func moreOwed(kept, candidate error) error { var cc *AttemptCutError if kept != nil || !errors.As(candidate, &cc) { return kept // already holding one, or not a cut at all — a 503 owes nobody anything } return candidate } // chainError attaches what the chain owes to whatever error ends it. It returns `final` untouched when // nothing is owed, when the owed cut IS what ended it (joining an error to itself prints the sentence // twice), or when `final` already carries a cut with a money claim at least as strong. // // ⚠ THE TEST IS THE MONEY, NOT THE TYPE. The version this replaces asked `errors.As(final, &cut)` and // returned early on any cut at all — true while money was drawn on `Delivered`, false the moment it was // drawn on `Billable`: a later FREE cut then erased an earlier PAID one and the engine booked $0. func chainError(final, owed error) error { if owed == nil || errors.Is(final, owed) { return final } var fc *AttemptCutError if errors.As(final, &fc) { var oc *AttemptCutError if errors.As(owed, &oc) && oc.Billable && !fc.Billable { // ⛔ THE OWED CUT GOES FIRST, and the order is the whole assertion. `errors.As` hands back the // FIRST match it meets walking the tree, so joining the free cut ahead of the paid one leaves // the caller settling from the free one — the very masking this branch exists to undo. return errors.Join(owed, final) } return final } return errors.Join(final, owed) } // nextBackoff computes the wait before attempt att+1 (0-based att just failed): // exponential base< 20 { shift = 20 // guard the shift against overflow at large max_attempts } backoff := profile.BackoffBase << uint(shift) if backoff <= 0 || backoff > profile.BackoffCap { backoff = profile.BackoffCap } if ra := retryAfterOf(lastErr); ra > 0 { if ra > maxRetryAfterWait { ra = maxRetryAfterWait } // 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) } // 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. type openAIClient struct { name string // provider label for logs/errors ("deepseek", "glm", "local") base string key string http *http.Client profile RetryProfile headers map[string]string // extra static headers (provider-specific), may be nil log *slog.Logger // floorWarned fires the «this provider has no measured speed» notice ONCE per client rather than // once per call: the fact is about the configuration, and a wave of forty chunks would otherwise // print it forty times and teach the operator to scroll past it. floorWarned sync.Once } // newOpenAIClient builds the shared transport. httpc may be nil (default // client); the local provider passes an explicit no-proxy client (the stand's // 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 = keepAliveHTTPClient() } return &openAIClient{ name: name, base: base, key: key, http: httpc, profile: profile.withDefaults(), headers: headers, log: logger, } } // --- OpenAI-compatible wire types ------------------------------------------------- type openAIMessage struct { Role string `json:"role"` Content string `json:"content"` } // openAIRequest is the neutral pre-wire request. Its body is assembled by // MarshalJSON THROUGH the per-model Capability resolver (capability.go), not // from fixed json tags: OpenAI-compatible providers share an endpoint but not a // body — the budget key, whether temperature is emitted, and the reasoning // switch all vary per model (D3.1). The Phase-0 fixed body (max_tokens + // always-send temperature + reasoning_effort) 400s on Kimi / gpt-5 / Gemini; // the resolver renders the correct shape and is itself folded into the job // snapshot, so changing it invalidates checkpoints rather than false-hitting. type openAIRequest struct { model string messages []openAIMessage maxTokens int temperature float64 stream bool // reasoningEffort is the NEUTRAL effort ("" | off | low | medium | high); // cap decides how — and whether — it reaches the wire. reasoningEffort string responseFormat any cap Capability // extra carries per-model provider-specific fields from models.yaml. Merged // LAST and only into gaps — a resolved standard key always wins. extra map[string]any } // MarshalJSON assembles the wire body via the capability resolver. Resolved // standard keys are authoritative; config-supplied extra_body fills only gaps. func (r openAIRequest) MarshalJSON() ([]byte, error) { m := map[string]any{ "model": r.model, "messages": r.messages, "stream": r.stream, } r.cap.applyToBody(m, r.maxTokens, r.temperature, r.reasoningEffort) if r.responseFormat != nil { m["response_format"] = r.responseFormat } mergeBody(m, r.extra) return json.Marshal(m) } type openAIUsage struct { PromptTokens int `json:"prompt_tokens"` CompletionTokens int `json:"completion_tokens"` // TotalTokens is prompt+completion for spec providers, but for Gemini via the // OpenAI-compat layer it ALSO carries the mandatory thinking tokens, which do NOT // appear in completion_tokens and have no reasoning_tokens field (live-probe // 2026-07-10: completion=2, prompt=22, total=847 → 823 hidden thinking). The // ReasoningAdditiveTotal adapter derives thinking = total − prompt − completion. TotalTokens int `json:"total_tokens"` PromptTokensDetails struct { CachedTokens int `json:"cached_tokens"` } `json:"prompt_tokens_details"` // xAI reports reasoning tokens here SEPARATELY from completion_tokens and // bills them at the output rate; OpenAI-spec providers count them inside // completion_tokens. The adapter decides which semantics apply. CompletionTokensDetails struct { ReasoningTokens int `json:"reasoning_tokens"` } `json:"completion_tokens_details"` // DeepSeek historically reports cache usage in its own top-level fields // instead of prompt_tokens_details. Parse both so the Phase-0 cache // experiment can't show a false zero (03-implementation-notes §3.5). PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"` PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"` } // cacheRead returns the cache-hit token count whichever field the provider // used. func (u openAIUsage) cacheRead() int { if u.PromptTokensDetails.CachedTokens > 0 { return u.PromptTokensDetails.CachedTokens } return u.PromptCacheHitTokens } type openAIResponse struct { ID string `json:"id"` Model string `json:"model"` Choices []struct { Message struct { Content string `json:"content"` } `json:"message"` FinishReason string `json:"finish_reason"` } `json:"choices"` Usage openAIUsage `json:"usage"` } func (r *openAIResponse) Text() string { if len(r.Choices) == 0 { return "" } return r.Choices[0].Message.Content } func (r *openAIResponse) FinishReason() string { if len(r.Choices) == 0 { return "" } // OpenAI-compat wire values map 1:1 onto the neutral constants. return r.Choices[0].FinishReason } // complete calls Chat Completions with retry on transient failures (429 / 5xx / // network, exponential backoff + jitter, Retry-After honoured). Non-retryable // 4xx fail immediately. On exhaustion the caller releases the reservation, so // a transient failure is never silently swallowed. func (c *openAIClient) complete(ctx context.Context, reqBody openAIRequest) (*openAIResponse, error) { payload, err := json.Marshal(reqBody) if err != nil { return nil, err } billedDecodeSeen := 0 deliveredCutSeen := 0 // ⛔ TWO DIFFERENT COUNTS, and they were one field. `deliveredCutSeen` is a RETRY CAP keyed on cuts. // `askedToGenerate` is what the operator is told — how many times this request reached the provider // inside one chain — and a chain delivers in more ways than by being cut: an undecodable 2xx that // already billed, a terminal 4xx, a 503. Counting only the cuts made the ledger line understate a // mixed chain while calling itself «the provider was asked N times». askedToGenerate := 0 // ⛔ EVERY CUT THE CHAIN MADE IS RE-STAMPED, not only the newest. The count is a property of the // CHAIN, and chainError deliberately hands up an EARLIER cut when that is the one that owes money — // carrying a number frozen at the moment it was born. A chain that cut once and was then answered // two 503s delivered three times and reported one, under-stating the very gap the line exists to // show. The later deliveries are not cuts, so nothing in the error tree knows about them; only this // counter does. var chainCuts []*AttemptCutError return retryLoop(ctx, c.profile, c.name, c.log, func() (*openAIResponse, bool, error) { resp, retryable, err := c.attempt(ctx, payload, reqBody.maxTokens) if deliveredAttempt(resp, err) { askedToGenerate++ } defer func() { for _, cc := range chainCuts { cc.Deliveries = askedToGenerate } }() // Cap a DELIVERED cut's re-calls at ONE, for the same reason and by the same shape as the // billed-decode cap above — but keyed on DELIVERY rather than on a 2xx. That is the whole // correction: on a provider that answers 200 while the request is still queued (DeepSeek // documents exactly that), «did a 2xx arrive» says nothing about whether a generation was // bought, while «did the request go out» says it exactly. A broken connection after delivery // is worth one more call; a second is a dead provider, not a flaky socket. if err != nil { var cut *AttemptCutError if errors.As(err, &cut) { deliveredCutSeen++ // The provider has now been asked to generate this many times, and the caller is told: // one settle will cover all of them, because the store writes spend only through a // checkpoint and they share one key. chainCuts = append(chainCuts, cut) if retryable && deliveredCutSeen > 1 { return resp, false, err } } } // 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 }) } // deliveredAttempt reports whether ONE attempt's request reached the provider — the question the // operator's «asked N times» line answers, and a different question from «did it end in a cut». // // A status of any kind is delivery by definition: the peer had to read the request to answer it. So is a // 2xx whose body would not parse — that one has already billed. A cut says so itself. What is NOT a // delivery is everything that failed before the bytes left: a refused connect, a DNS failure, a write // that died mid-body. func deliveredAttempt(resp *openAIResponse, err error) bool { if err == nil { return resp != nil } var cut *AttemptCutError if errors.As(err, &cut) { return cut.Delivered } var hse *HTTPStatusError var bde *BilledDecodeError return errors.As(err, &hse) || errors.As(err, &bde) } // attempt performs one HTTP call. Returns retryable=true for 429/5xx and // network errors, false for other non-2xx (terminal 4xx). The per-attempt // deadline is derived from THIS call's output budget (attemptcut.go); the // overall per-request deadline (set by the caller via ctx) bounds the whole retry loop. // // maxTokens is the budget the body already carries. It is passed rather than re-parsed so the // deadline and the request can never describe different calls. func (c *openAIClient) attempt(ctx context.Context, payload []byte, maxTokens int) (*openAIResponse, bool, error) { deadline := c.attemptDeadline(ctx, maxTokens) attemptCtx, cancel := context.WithTimeout(ctx, deadline) defer cancel() // The delivery facts are collected by the transport itself: a request is DELIVERED once its bytes // are written, which is knowable before any reply exists and is the boundary the money is drawn on. var tr deliveryTrace started := time.Now() req, err := http.NewRequestWithContext(httptrace.WithClientTrace(attemptCtx, tr.clientTrace()), http.MethodPost, c.base+"/chat/completions", bytes.NewReader(payload)) if err != nil { return nil, false, err } req.Header.Set("Content-Type", "application/json") // A local backend (ollama / llama-server) usually runs without auth; an // empty key means "no Authorization header", not "Bearer " with an empty token. if c.key != "" { req.Header.Set("Authorization", "Bearer "+c.key) } for k, v := range c.headers { req.Header.Set(k, v) } resp, err := c.http.Do(req) if err != nil { // A DELIVERED request that never answered: the provider has it and is (or was) working, so // this is a money event and carries its cause. An UNDELIVERED one stays exactly what it was — // a plain retryable transport failure whose reservation is released, the one case where // «nothing was bought» is true by construction. if cut := c.cutError(ctx, attemptCtx, &tr, started, nil, err, false); cut != nil { return nil, cut.retryable(), cut } // The bare «context deadline exceeded» doesn't tell the operator WHOSE deadline it is — // this call's own (derived from its budget) or the whole run cancelled. if errors.Is(attemptCtx.Err(), context.DeadlineExceeded) && ctx.Err() == nil { err = fmt.Errorf("attempt timed out after %s (derived from max_tokens=%d; timeouts.attempt_s is its floor) before the request was delivered: %w", deadline, maxTokens, err) } return nil, ctx.Err() == nil, err } defer resp.Body.Close() // Read one byte past the limit to DISTINGUISH truncation from a whole body — for the >16 MiB case // alone. Whether the read COMPLETED is a separate question with a separate answer, and dropping // readErr here is what made a body our own deadline cut short (small) indistinguishable from a // whole one, so it went down the retryable branch and bought the same generation twice. data, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) truncated := len(data) > maxResponseBytes if truncated { data = data[:maxResponseBytes] } obs.LogLLMExchange(ctx, c.log, c.name, payload, resp.StatusCode, data) // ⛔ THE STATUS LINE IS ASKED BEFORE THE READ ERROR, and the order is the money. A non-2xx says the // provider refused or failed — nothing was generated and nothing is owed — so a body cut short // under it is a detail of a failure, not a purchase. Reading them the other way round would settle // an estimate for every 4xx whose tiny body happened to land on the deadline. The partial body is // still what the retry/terminal split reads (a truncated marker just fails to match and the status // stays retryable, the conservative direction), and the read error rides in the message so a // misclassified 429 leaves a trace instead of none. if resp.StatusCode < 200 || resp.StatusCode >= 300 { retryable := retryableStatus(resp.StatusCode, data) e := &HTTPStatusError{Provider: c.name, Status: resp.StatusCode, Body: snippet(data)} if readErr != nil { e.Body = snippet(data) + fmt.Sprintf(" [body read incomplete: %v]", readErr) } if retryable { e.RetryAfter = parseRetryAfter(resp.Header) // only a retryable status will honour it } return nil, retryable, e } if readErr != nil { // A 2xx whose body we did not receive whole. Headers had arrived, so the request was written // by definition — but ask the trace rather than assume it, and let an undelivered // impossibility fall through to the old shape instead of settling money on a deduction. if cut := c.cutError(ctx, attemptCtx, &tr, started, data, readErr, true); cut != nil { return nil, cut.retryable(), cut } return nil, ctx.Err() == nil, readErr } var out openAIResponse if err := json.Unmarshal(data, &out); err != nil { // A 2xx with an unreadable body: the provider has ALREADY charged. We type it // so the runner conservatively settles at the estimate. A truncated (>16 MiB) // body is deterministic — retrying is pointless (each retry is a new billed // 2xx), so terminal; a broken connection / garbage is retryable. if truncated { return nil, false, &BilledDecodeError{Provider: c.name, Err: fmt.Errorf("response exceeded %d bytes (truncated, not retried): %w", maxResponseBytes, err)} } return nil, true, &BilledDecodeError{Provider: c.name, Err: err} } // A 2xx is a billed call even when the model returns empty content // (content filter, finish_reason=length with no text). Return it as a // success so the caller settles the real cost via the ledger instead of // releasing the reservation and losing the spend — which would let empty // replies bypass the book/day ceilings. Gates deal with the empty text. return &out, false, nil } // 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, // not be masked by a fallback — instead of parsing the message. type HTTPStatusError struct { Provider string Status int Body string RetryAfter time.Duration // from the Retry-After header; 0 = absent } func (e *HTTPStatusError) Error() string { return fmt.Sprintf("%s http %d: %s", e.Provider, e.Status, e.Body) } // BilledDecodeError is a 2xx whose body could not be decoded: the provider has // billed the call, but usage/text are unknown. The runner treats an exhausted // retry chain ending in this error as BILLED (settle at the reservation // estimate) rather than releasing the reservation. type BilledDecodeError struct { Provider string Err error } func (e *BilledDecodeError) Error() string { return fmt.Sprintf("%s: 2xx body decode failed (call IS billed): %v", e.Provider, e.Err) } func (e *BilledDecodeError) Unwrap() error { return e.Err } func retryAfterOf(err error) time.Duration { var se *HTTPStatusError if errors.As(err, &se) { return se.RetryAfter } return 0 } // toOpenAIMessages maps neutral messages onto the wire. CacheBoundary is // meaningless here: OpenAI-compatible caches (DeepSeek: prefix-match from token // 0 in blocks of 64 tokens) are automatic; the stable-prefix ORDER the // assembler produced is all that matters — which is also why JOINING the system // run under SystemMessagesSingle costs no cache: the stable prefix keeps its // bytes and its position, it merely stops being its own message. // // mode is the endpoint's system-message cardinality (Capability.SystemMessages). // Under SystemMessagesSingle the LEADING system run is joined into one message so // an endpoint with one system slot receives the memory-bank injection instead of // silently dropping it; a system message AFTER a non-system turn is refused loud, // exactly as the Anthropic adapter refuses it — our assembler never produces one, // and a join that quietly re-ordered a conversation would be a worse lie than the // one this function exists to stop. Under SystemMessagesMulti (the default) the // mapping is one-for-one, byte-identical to the wire before this axis existed. func toOpenAIMessages(msgs []Message, mode SystemMessagesMode) ([]openAIMessage, error) { if mode != SystemMessagesSingle { out := make([]openAIMessage, len(msgs)) for i, m := range msgs { out[i] = openAIMessage{Role: m.Role, Content: m.Content} } return out, nil } var systemRun []string out := make([]openAIMessage, 0, len(msgs)) inSystemPrefix := true for _, m := range msgs { if m.Role == "system" { if !inSystemPrefix { return nil, fmt.Errorf("llm: this endpoint carries a single system message and got one after a non-system turn; joining it would re-order the conversation") } systemRun = append(systemRun, m.Content) continue } if inSystemPrefix { inSystemPrefix = false if len(systemRun) > 0 { out = append(out, openAIMessage{Role: "system", Content: strings.Join(systemRun, systemJoinSeparator)}) } } out = append(out, openAIMessage{Role: m.Role, Content: m.Content}) } // A message list that is system-only (no user turn) still has to ship its join. if inSystemPrefix && len(systemRun) > 0 { out = append(out, openAIMessage{Role: "system", Content: strings.Join(systemRun, systemJoinSeparator)}) } return out, nil } // jsonResponseFormat returns the response_format value for JSONOnly requests // (nil otherwise, so the field serializes away). func jsonResponseFormat(jsonOnly bool) any { if jsonOnly { return map[string]string{"type": "json_object"} } return nil } // 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 } // 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 { return d } } return 0 } func snippet(b []byte) string { const max = 300 if len(b) > max { return string(b[:max]) + "…" } return string(b) }