textmachine/backend/internal/llm/attemptcut.go

343 lines
19 KiB
Go

package llm
import (
"context"
"errors"
"fmt"
"net/http/httptrace"
"strings"
"sync/atomic"
"time"
)
// attemptcut.go: what OUR OWN deadline does to a call, and the deadline itself.
//
// A provider that has accepted a request generates whether or not we are still listening. When the
// attempt deadline fires mid-generation the connection drops on our side, the provider never learns
// it, and the call is billed all the same — so «we stopped waiting» is a MONEY event, not a transport
// one. The transport used to lose it twice over: the body read's error went to `_`, and truncation was
// judged by SIZE, so a body our own deadline cut short (small) was indistinguishable from a whole one
// and went down the retryable «broken connection» branch that the same code's comment forbids for a
// billed 2xx («each retry is a new billed 2xx»).
//
// THE ONE FACT COMMON TO EVERY SUCH CASE IS THAT THE REQUEST WAS DELIVERED, and it is observable
// without a byte on the wire: net/http/httptrace reports when the request has been WRITTEN and when
// the first response byte arrived.
//
// ⚠ NO SURVEYED HARNESS DRAWS THE BOUNDARY HERE, and that is a fact rather than a claim of novelty:
// research/21 covers eleven of them and mentions httptrace nowhere (0 hits against 20 for openai-go).
// The reason is what they are: five are stream-only and four do both (§Q6), so «did anything arrive» is
// answered by the first event and the question never comes up; the remaining two are generic SDKs whose
// non-streaming answer is to REFUSE a long call outright. On a non-streaming path with no refusal to
// fall back on it has to be answered another way, and the stdlib already answers it — so the mechanism
// is stdlib rather than ours (research/21's own rule: an industrial primary source before a home-made one).
//
// Those two booleans separate the three cases that a status line cannot:
//
// delivered, headers still pending, our deadline WroteRequest=true GotFirstResponseByte=false
// TLS handshake hanging — NOT delivered WroteRequest=false GotFirstResponseByte=false
// early 200 with empty lines, our deadline WroteRequest=true GotFirstResponseByte=true
//
// The middle row is the ONLY one where «nothing was bought» is true by construction. On DeepSeek the
// first and third are the live ones: the vendor documents an early 200 with empty lines while a
// request waits to be scheduled, so a 200 there means ACCEPTED, not GENERATED, and the branch that
// reads «No 2xx ever arrived: nothing was billed» is unreachable on it — every self-cut hid under
// decode_error instead.
// CutCause says WHO ended an attempt that had already been delivered. The three are dispositions,
// not shades of one error: our own deadline is not retried and is flagged, a run the operator
// stopped is re-done on resume at the same budget, and a broken connection gets one retry.
type CutCause string
const (
// CutBySelfDeadline: our per-attempt deadline fired while the provider was still working.
// Retrying buys the same generation a second time — the whole point of typing this.
CutBySelfDeadline CutCause = "attempt_timeout"
// CutByParent: the RUN ended (stop, Ctrl-C) under a call that had already gone out. The call was
// healthy; a human stopped it.
CutByParent CutCause = "cancelled"
// CutByConnection: the connection broke after delivery (unexpected EOF, an h2 stream reset) with
// both deadlines still alive. Transport-shaped, so it is worth exactly one retry.
CutByConnection CutCause = "connection_lost"
)
// AttemptCutError is a DELIVERED request whose reply we did not receive whole.
//
// ⚠ It is deliberately NOT named for the timeout: two of its three causes are not one (a run somebody
// stopped, a connection that broke), and the runner routes money, flag and resume differently for each.
// A type named `AttemptTimeoutError` carrying `Cause: cancelled` would be a name lying about a cause,
// which is the thing the flag vocabulary forbids outright (D39.93 п.2).
//
// Delivered is the money boundary and is always true on a value that reaches a caller — the
// constructor refuses to build one otherwise, because «not delivered» is the case that must keep
// costing nothing.
type AttemptCutError struct {
Provider string
Cause CutCause
// Delivered: the request bytes reached the provider (httptrace WroteRequest, no write error).
Delivered bool
// AfterHeaders: the first BYTE of a response had arrived when the call was cut (httptrace's
// GotFirstResponseByte) — the early-200 case.
//
// ⚠ IT IS A BYTE, NOT A REPLY, and the distinction is the whole reason `Billable` is not this field.
// The first byte fires for a 1xx, for a header block that never terminated, for a broken proxy's
// garbage — none of which is a provider acknowledging anything. Read as «a response arrived» it
// tells an operator about a reply that did not exist; read as what it is, it is the evidence that
// something came back down the socket, and the money question is answered by `Billable`, which also
// requires a 2xx object in hand.
AfterHeaders bool
// BytesRead is how much of the body we did get. It is EVIDENCE, never the criterion: judging
// truncation by size is the defect this type replaces.
BytesRead int
// WhitespaceOnly says the bytes we got carry no content at all (DeepSeek's documented empty lines
// while a request waits for scheduling). It distinguishes «the provider was still queueing» from
// «the provider was mid-answer», which is the difference between a wait worth extending and a
// generation worth not buying twice.
WhitespaceOnly bool
Elapsed time.Duration
// Billable is the MONEY boundary, and it is deliberately NARROWER than Delivered.
//
// ⛔ TWO PREDICATES, BECAUSE THEY ANSWER TWO QUESTIONS. `WroteRequest` says our bytes left for the
// peer's TCP window; it says nothing about the APPLICATION behind it — a load balancer can accept
// while the backend never sees the request. Measured on a stopped run: 3 of 25 cancelled in-flight
// calls settled an estimate for a request no handler ever entered. On a non-streaming wire the one
// signal that a provider's application acknowledged the request is a 2xx response object reaching
// US, so that is what money is drawn on. Delivery still drives RETRY — a written request is not
// automatically re-sent — and that boundary is unchanged.
//
// The direction of what remains is the tolerable one: on a provider that HOLDS its headers, a
// pre-header self-cut books zero, i.e. an under-count. On DeepSeek nothing is lost at all — its 200
// arrives on acceptance, so every self-cut there is post-header.
Billable bool
// Deliveries is how many times THIS request reached the provider inside one retry chain — counted by
// DELIVERY, not by cause, so a chain that was cut once and answered an undecodable 2xx once reports
// two. It is almost always 1; a broken connection is worth one retry, and then the provider has been
// asked to generate TWICE while the ledger books ONE estimate — the store cannot write two settles
// under one key. The number is carried rather than acted on: making the gap visible is this pack's
// business, deciding what it costs is the owner's.
Deliveries int
// Err is the transport error that ended the attempt.
Err error
// Parent is the parent context's own cause, set ONLY for CutByParent. It rides here so that
// errors.Is(err, context.Canceled) keeps deciding the process exit code while errors.As still
// finds this type: the exit contract and the money both need to be true of one error.
Parent error
}
func (e *AttemptCutError) Error() string {
where := "before any response byte"
if e.AfterHeaders {
where = fmt.Sprintf("after %d body bytes", e.BytesRead)
if e.WhitespaceOnly {
where += " (whitespace only)"
}
}
return fmt.Sprintf("%s: delivered request cut by %s %s after %s: %v",
e.Provider, e.Cause, where, e.Elapsed.Round(time.Millisecond), e.Err)
}
// Unwrap returns both truths of a cancelled attempt. Go's errors.Is/As walk every branch, so the
// caller that asks «did the run end» and the caller that asks «what did it interrupt» both get a
// straight answer from the same value.
func (e *AttemptCutError) Unwrap() []error {
if e.Parent != nil {
return []error{e.Err, e.Parent}
}
return []error{e.Err}
}
// deliveryTrace records the two httptrace facts the money boundary is drawn from. The callbacks run
// on the transport's goroutine while the caller may already be reading the fields (a deadline fires
// concurrently with a write completing), so both are atomics rather than plain bools.
type deliveryTrace struct {
wrote atomic.Bool
firstByte atomic.Bool
}
func (t *deliveryTrace) clientTrace() *httptrace.ClientTrace {
return &httptrace.ClientTrace{
// info.Err non-nil means the write itself failed — the request did NOT reach the provider,
// and treating that as delivery would settle money for a call nobody received.
WroteRequest: func(info httptrace.WroteRequestInfo) {
if info.Err == nil {
t.wrote.Store(true)
}
},
GotFirstResponseByte: func() { t.firstByte.Store(true) },
}
}
// delivered reports that the request reached the provider. `answered` says the transport handed us an
// actual 2xx response, and it is the second half of the evidence — needed, and needed CONDITIONALLY.
//
// ⛔ A RESPONSE BYTE IS DELIVERY EVIDENCE, BUT ONLY WHEN A REPLY ACTUALLY CAME BACK TO US. net/http does
// not wait for the write loop before handing back a response: Request.write fires WroteRequest from a
// deferred call on the writeLoop goroutine (net/http/request.go) while roundTrip returns as soon as the
// response channel fires (net/http/transport.go), and golang.org/x/net/http2 has the same shape. So a
// provider answering EARLY — precisely what DeepSeek documents doing while a request waits to be
// scheduled — can have its 200 overtake our own «the request was written» callback whenever the body is
// still draining. Read as «not delivered», that call books $0 and is retried: measured at three re-asks.
//
// ⛔⛔ AND THE OTHER READING COSTS MORE. Taking the response BYTE alone as proof, whether or not a reply
// reached us, pays for refusals: a provider that answers 401/403/413 and resets the connection while our
// body is still writing gives GotFirstResponseByte=true and a WRITE ERROR, so http.Client.Do returns the
// write failure and we never see the status at all. Measured on a copy of this tree: 22 refusals in 25
// booked a paid cut, one of them $0.80 for a request the provider declined — and the delivered-cut retry
// sent the whole body a second time. When Do fails we hold no status line and cannot tell a refusal from
// an early success, so only the write counts; when Do SUCCEEDS with a 2xx, the reply is proof by itself.
func (t *deliveryTrace) delivered(answered bool) bool {
return t.wrote.Load() || (answered && t.firstByte.Load())
}
func (t *deliveryTrace) afterHeaders() bool { return t.firstByte.Load() }
// causeOf names who ended a delivered attempt. The order is the meaning: the PARENT is asked first,
// because a run that is ending has cancelled the attempt context too, and reading our own deadline
// first would file every stopped run as a self-cut and flag chunks nobody's provider misbehaved on.
func causeOf(ctx, attemptCtx context.Context) CutCause {
switch {
case ctx.Err() != nil:
return CutByParent
case errors.Is(attemptCtx.Err(), context.DeadlineExceeded):
return CutBySelfDeadline
default:
return CutByConnection
}
}
// cutError builds the typed error for a delivered attempt, or NIL when the request never went out.
// The «not delivered» path is the one case where nothing was bought, and it must stay exactly what it
// was: a plain retryable transport failure that releases the reservation. Nil rather than «the error
// unchanged» so the caller branches on a value instead of on error identity — the shape that survives
// somebody wrapping the transport error one layer deeper.
// `answered` is true only where a 2xx response object is in hand — see delivered.
func (c *openAIClient) cutError(ctx, attemptCtx context.Context, tr *deliveryTrace, started time.Time, body []byte, err error, answered bool) *AttemptCutError {
if !tr.delivered(answered) {
return nil
}
cause := causeOf(ctx, attemptCtx)
cut := &AttemptCutError{
Provider: c.name, Cause: cause,
Delivered: true, AfterHeaders: tr.afterHeaders(),
Billable: answered && tr.afterHeaders(),
BytesRead: len(body), WhitespaceOnly: len(body) == 0 || strings.TrimSpace(string(body)) == "",
Elapsed: time.Since(started), Err: err,
}
if cause == CutByParent {
cut.Parent = ctx.Err()
}
return cut
}
// retryable is the retry half of the disposition, kept beside the causes so the two cannot drift.
// Only a broken connection is worth another call: our own deadline firing means the provider is STILL
// GENERATING what we just stopped listening to, and retrying buys that generation a second time — the
// defect this file exists to remove. A cancelled run has nothing to retry into.
func (e *AttemptCutError) retryable() bool { return e.Cause == CutByConnection }
// --- the deadline itself (backlog row 360 point 5, row 369) ---
// The industry reference for sizing a non-streaming call, and the source of the default rate:
// anthropic-sdk-go's CalculateNonStreamingTimeout budgets 1h · max_tokens / 128 000 and REFUSES a
// request whose expected time exceeds ten minutes («streaming is required»). Ours is the same
// arithmetic with the rate made per-provider data and the vendor's queue wait added, because we have
// no streaming path to fall back to and must wait instead of refusing (research/21 §Q6 + its 08.09
// errata; openai-go's ResponseHeaderTimeout is the same idea applied to time-to-headers alone).
//
// It is the default because it is the one number that is not ours to invent — and it is the number
// `attempt_s: 240` was silently standing in for: 240 s at this rate is ~8.5k tokens, the DRAFT's
// budget, and the same 240 was carried to an editor budgeted at 16 000 with a doubling to 32 000.
const (
vendorHourlyTokenBudget = 128000
vendorBudgetWindow = time.Hour
)
// defaultTokensPerSecFloor is DERIVED from the pair above and never typed as a decimal: writing
// 35.5 here would be a second carrier of a number the vendor states as 128 000 per hour, and the two
// would drift the day either moved.
func defaultTokensPerSecFloor() float64 {
return float64(vendorHourlyTokenBudget) / vendorBudgetWindow.Seconds()
}
// deriveDeadline is the UNCLAMPED time this profile says a call for maxTokens needs: the vendor's
// documented wait before generation starts, plus the generation itself at the slowest speed the
// model has been observed to hold. It is a pure function of the profile and the budget — the budget
// the call actually carries, so an escalated attempt that doubled its max_tokens doubles its time
// instead of inheriting the budget of an attempt that asked for half as much.
//
// An unset or nonsensical floor falls back to the vendor default rather than to a division by zero
// or a zero deadline: a config that forgot the field must wait too long, never not at all.
func (p RetryProfile) deriveDeadline(maxTokens int) time.Duration {
floor := p.TokensPerSecFloor
if floor <= 0 {
floor = defaultTokensPerSecFloor()
}
if maxTokens < 0 {
maxTokens = 0
}
return p.QueueSlack + time.Duration(float64(maxTokens)/floor*float64(time.Second))
}
// DeadlineFor clamps the derivation between the configured attempt_s and attempt_max_s. It is exported
// because it answers a question about a CONFIGURATION rather than about a call in flight — «how long
// will one call of this size wait under this profile» — and the catalogue gate over models.yaml has to
// ask it of a provider nobody has called yet.
//
// ⛔ attempt_s is the FLOOR, not the value. That is what makes this change safe to land on every
// existing config at once: a call whose derived time is shorter than the configured deadline keeps
// the configured one, so no provider loses a second it has today, and only calls that provably
// could not finish get more. attempt_max_s bounds the other end — an operator's ceiling on how long
// one call may hold a reservation — and is inert when unset.
func (p RetryProfile) DeadlineFor(maxTokens int) time.Duration {
d := p.deriveDeadline(maxTokens)
if d < p.AttemptTimeout {
d = p.AttemptTimeout
}
if p.AttemptMax > 0 && d > p.AttemptMax {
d = p.AttemptMax
}
return d
}
// attemptDeadline is the client's own wrapper: the same clamp, plus the ONE notice an operator needs
// when a provider is running on the vendor default. Waiting a quarter of an hour for a call is a
// legitimate configuration — the owner ratified waiting up to ~20 minutes — but it must never be a
// surprise, and «why is nothing happening» is the pain this line answers before it is felt.
//
// The condition is structural rather than a threshold in tokens: it fires exactly when the derived
// time OVERRIDES the configured attempt_s, i.e. when this call is one the configured deadline could
// not have covered. A provider whose calls all fit inside its own attempt_s never sees it.
func (c *openAIClient) attemptDeadline(ctx context.Context, maxTokens int) time.Duration {
d := c.profile.DeadlineFor(maxTokens)
if c.profile.TokensPerSecFloor <= 0 && d > c.profile.AttemptTimeout {
c.floorWarned.Do(func() {
if c.log == nil {
return
}
c.log.WarnContext(ctx, "no measured generation speed for this provider; the call deadline is derived from the vendor default and is longer than the configured attempt_s — set timeouts.tok_s_floor from this provider's own request_log after the first run",
"provider", c.name, "default_tok_s", fmt.Sprintf("%.1f", defaultTokensPerSecFloor()),
"max_tokens", maxTokens, "derived_deadline", d.String(), "attempt_s", c.profile.AttemptTimeout.String())
})
}
return d
}
// cancelledDuring is what a stopped run returns: BOTH the cancellation, which decides the process exit
// code, and whatever the attempt underneath it was — which is what decides the money.
//
// ⛔ IT IS errors.Join AND NOT A CHOICE BETWEEN THEM. Returning the attempt's error alone loses
// `errors.Is(err, context.Canceled)` for every attempt error that does not happen to wrap the
// cancellation — a stopped run would then leave with a foreign exit code. Returning the cancellation
// alone loses `errors.As`, and with it the money for a call already on the wire, which is the defect
// this whole file exists to remove. Join keeps both true of one value, and it does so whatever the
// attempt's cause was: an earlier form of this guard preserved only the errors that already carried the
// parent's own error, so a connection_lost or attempt_timeout whose run was stopped a moment later was
// still silently reduced to a bare cancellation.
func cancelledDuring(ctxErr, attemptErr error) error {
if attemptErr == nil {
return ctxErr
}
if errors.Is(attemptErr, ctxErr) {
return attemptErr // it already carries both; joining would only duplicate the sentence
}
return errors.Join(ctxErr, attemptErr)
}