Skip to content
SeeItFlow
System Design · 11 chapters · ~32 min read

AI Gateway Architecture: Routing, Quotas & Streaming

This page is the mechanics page: the exact request-lifecycle pseudocode, the atomic admission algorithms, the routing scoring function, the adapter capability contracts, and the streaming and control-plane internals that Complete Design introduced at a narrative level. Read Complete Design first if you haven't — this page assumes you already have the end-to-end shape in mind.

Mangesh MandlikLast updated: How lessons are made
Chapter 01

Request Lifecycle Pseudocode

Every request — streaming or not — moves through the same seven stages. The ordering is load-bearing: authorization happens before any upstream call, admission (the atomic reservation) happens before routing, and settlement happens exactly once however the request ends: success, failure before the response started, failure after it started, or client cancellation.

Swipe to pan
Request Lifecycle (Happy Path)authenticate → authorize → reserve → route → adapt → stream → finalizeClientAuth(vkey)ACLReserve(quota)RouteAdapterProviderFinalize(ledger)
authenticate → authorize → reserve → route → adapt → stream → finalize
textonRequest(req) and its helpers — the complete gateway request path, with one reservation per request
function onRequest(req):
    # 1. AUTHENTICATE — validate the virtual key, never trust caller-supplied headers
    tenant = authenticate(req.virtualKey)
    if tenant is null: return 401 Unauthorized

    # 2. AUTHORIZE — tenant + logical model + capability policy check
    policy = loadPolicy(tenant, cacheOk=true)          # control-plane snapshot, not a live call
    if not policy.allows(req.logicalModel, req.capabilities):
        return 403 Forbidden                             # rejected before any upstream cost

    # 3. ESTIMATE — worst-case token/cost bound for this request
    estInputTokens  = tokenize(req.prompt, model=req.logicalModel)
    estOutputTokens = req.maxTokens ?? policy.defaultMaxTokens
    estCostUsd      = price(policy, estInputTokens, estOutputTokens)

    # 4. RESERVE — one reservation per client request, created once and owned by this
    #    request until it settles. Atomic across every gateway replica (Invariants I2, I3),
    #    and idempotent on req.requestId so a client retry cannot reserve twice.
    reservation = quotaStore.tryReserveAtomic(
        id=req.requestId, tenant=tenant, rpmUnits=1,
        tpmUnits=estInputTokens + estOutputTokens, usdUnits=estCostUsd,
        expiresIn=policy.reservationTtl)
    if not reservation.ok:
        return 429 TooManyRequests, Retry-After: reservation.retryAfterSeconds

    outcome = Outcome.NO_ATTEMPT
    try:
        outcome = executeWithFailover(req, tenant, policy, reservation)   # steps 5-6
    finally:
        settle(reservation, outcome)          # step 7: runs on success, failure and client cancel
        # If this process dies before reaching here, the reservation expires and the
        # reconciler settles it (see "Crash recovery and reconciliation").
    return outcome.toClientResponse()


function executeWithFailover(req, tenant, policy, reservation):
    deadline = now() + policy.maxRequestSeconds     # total time budget across ALL attempts
    tried = []
    while len(tried) < policy.maxAttempts and now() < deadline:
        # 5. ROUTE — filter on hard constraints, then score the eligible set
        eligible = [d for d in modelRegistry.deploymentsFor(req.logicalModel)
                    if d not in tried
                    and d.supportsResidency(tenant.region)
                    and d.supportsCapabilities(req.capabilities)
                    and d.circuitState != OPEN]
        if eligible.isEmpty(): break
        deployment = highestScored(eligible, weights=policy.routingWeights)
        tried.append(deployment)

        # 6. ADAPT + CONNECT — every attempt runs under the SAME reservation
        attempt = reservation.recordAttempt(deployment)   # marks that upstream may now be running
        adapter = adapters[deployment.provider]
        try:
            stream = adapter.call(deployment, adapter.translate(req),
                                  timeout=min(policy.attemptTimeout, deadline - now()))
            first  = stream.awaitFirstChunk()
        except ProviderRejected:            # 429/5xx/connect error: provider did not run it
            attempt.markNotExecuted()       # costs nothing, so no extra reservation needed
            continue
        except UpstreamTimeout:             # request was sent, no answer: it MAY have executed
            attempt.markUnknownUsage()      # reconciled later from the provider's usage report
            if not quotaStore.extendReservation(reservation.id, estimateFor(deployment)):
                break                       # cannot cover a possible second execution: stop
            continue

        # First byte reaches the client: the response is now committed, no more failover.
        return relay(req.clientConn, stream, first, attempt)

    return Outcome.fromAttempts(tried)      # NO_ELIGIBLE (503) or EXHAUSTED (502/504)


function relay(clientConn, stream, first, attempt):
    try:
        clientConn.write(first)
        for chunk in stream:                              # chunks already normalized by the adapter; bounded buffer
            clientConn.write(chunk)
        return Outcome.COMPLETE(usage=stream.reportedUsage)
    except UpstreamError:                                 # failure AFTER the response started
        emitStreamError(clientConn, incomplete=true)      # Invariant I4: never splice another generation
        return Outcome.FAILED_MIDSTREAM(usage=stream.reportedUsageOrNull)
    except ClientDisconnected:                            # caller cancelled
        stream.cancel()                                   # best effort; provider may still bill
        return Outcome.CLIENT_CANCELLED(usage=null)


function settle(reservation, outcome):
    if not reservation.anyAttemptMayHaveExecuted():       # nothing was sent, or every attempt was refused
        quotaStore.releaseIfReserved(reservation.id)      # RESERVED -> RELEASED, full refund, no-op if already settled
        return
    if outcome.usage is null or reservation.hasUnknownUsageAttempt():
        quotaStore.markPendingReconciliation(reservation.id)  # worst case stays charged until usage is known
        return
    ledger.insertUsage(reservation.id, outcome.usage, priceVersion=reservation.priceVersion)
        # durable, unique on reservation.id, so a replay inserts nothing new
    quotaStore.settleIfReserved(reservation.id, outcome.usage)
        # RESERVED -> SETTLED and refund (reserved - actual), in one atomic step; no-op if already settled
    telemetry.record(reservation.correlationId, reservation.tenant, outcome)
Chapter 02

Virtual Keys & Credential Management

A virtual key is a credential the gateway's own control plane issues; the provider never sees it, and the provider's real credential never leaves the gateway process boundary.

Swipe to pan
Virtual Key → Tenant/Policy MappingThe real provider credential never crosses the gateway boundaryClientvk_team_aGateway: Authlookup tenant + policyCredential Vaultserver-side onlyProvidersk-live-… injectedInvariant I6 — virtual keys hashed at rest, provider secrets never logged
Virtual key → tenant/policy mapping — the real provider credential never crosses the gateway boundary

Storage

Virtual keys are hashed at rest exactly like password credentials (a keyed hash, e.g. HMAC-SHA256 with a server-side pepper) — a leaked database dump should not hand out usable keys. Only the hash and a short, non-secret prefix (for display/debugging, e.g. 'vk_live_8f2c…') are stored; the plaintext key is shown to the tenant exactly once, at creation.

Rotation without downtime

A tenant can hold two active virtual keys simultaneously during a rotation window — the old key keeps working until the tenant confirms the new one is deployed, then the old key is revoked. Revocation takes effect on the next policy snapshot, bounded by the freshness window discussed in Chapter 10.

Provider credential vaulting

Real provider API keys live in a secrets manager the control plane reads from, never in the policy snapshot pushed to gateways in plaintext — gateways receive a reference/alias and fetch the actual secret through a short-lived, audited path, or the control plane injects it into the adapter layer directly at call time. Either way, a compromised gateway replica's memory dump should expose at most the credentials for requests currently in flight, not the entire credential vault.

Chapter 03

Distributed Quota Algorithms

The admission check has to be correct across every gateway replica and fast enough to sit on every request's critical path. Three mechanisms cover the realistic design space.

Swipe to pan
Atomic Quota ReservationCheck-and-decrement is indivisible across replicas (Lua script / CAS row)gw1gw2Quota Store1 unit remainingreserve(team_a, 1)reserve(team_a, 1)RESERVED ✓decremented atomicallyDENIED — 429Retry-After: 4s
Atomic quota reservation — one replica's reservation succeeds, the other is denied with Retry-After

Token bucket, atomically decremented

A Redis Lua script (or equivalent single-round-trip atomic operation) checks the current bucket level and decrements it in one indivisible step: if bucket[key] >= cost then bucket[key] -= cost; return OK else return DENY. Because Redis executes Lua scripts atomically, two replicas racing for the last unit cannot both succeed — exactly the fix for the naive-local-counter race.

Sliding window vs. fixed window

A fixed window (reset every 60s on the clock) is simple but allows a burst of up to 2x the limit straddling a window boundary. A sliding window (weighted average of the current and previous window, or a sorted-set of timestamps) bounds the burst more tightly at the cost of slightly more storage and computation per check. See Rate Limiting for the general algorithm comparison — this gateway applies the identical choice independently to RPM and TPM.

RPM and TPM reserved independently

A single reservation call checks and decrements two counters in the same atomic script: request count and token count. Both must succeed for the reservation to succeed; if either is insufficient, the whole reservation is denied and neither counter is touched (no partial decrement).

Fail-mode policy when the quota store is unavailable

If the shared counter store itself is down, the gateway has three honest choices, and must pick one explicitly rather than default accidentally: fail closed (reject all requests — safest for budget, worst for availability), fail open with a conservative local fallback limit (bounded risk, preserves availability), or fail open with no limit (never acceptable for a financial resource). Most production designs fail closed for budget/TPM and fail open with a strict local fallback for RPM, reflecting that a request-count overshoot is cheaper than a budget overshoot.

Chapter 04

Token & Budget Reservation Mechanics

Reservation, settlement, and release are three distinct operations, each guarded by the reservation's status so that repeating any of them cannot charge or refund twice.

Reservation record

A reservation is a Redis hash stored next to the tenant's counters, keyed by the request ID: tenant, estimated input tokens, estimated output tokens (= requested cap), estimated USD, price version, status (RESERVED, SETTLED, RELEASED, PENDING_RECONCILIATION), a list of upstream attempts, created_at, and an expiry. A reservation that never settles, for example because the gateway process crashed mid-request, must not stay RESERVED forever, but it also must not simply be released: if an upstream call was started, the provider may have run it. The reconciler described below decides what to do.

Settlement

On completion the gateway does two things in a fixed order. First it inserts the usage record into the ledger, keyed uniquely by the reservation ID, so repeating it creates nothing new. Second it asks the quota store to settle: a single atomic script that checks the status is still RESERVED, marks it SETTLED, and refunds reserved minus actual to the tenant's counters. If the status is anything else, the script does nothing and returns the existing result, so a retried settle can never refund twice. These two steps are not one atomic operation, because the ledger and the quota store are different systems. The next sections explain what keeps them consistent.

Tokenizer and pricing drift

The estimate uses the tokenizer and price list in effect when the request was admitted. If pricing changes between reservation and finalization (a control-plane snapshot update mid-flight), the finalize step uses the price that was in effect at admission time, not the new one — otherwise a request's cost could change retroactively after the tenant already saw the estimate. This is a deliberate, documented choice, not an oversight.

Worked example

A request sets max_tokens=500. Reservation assumes the worst case — the full output cap — priced at realistic asymmetric per-token rates: input at $0.00003/token, output at $0.00012/token (completion tokens typically cost several times more than prompt tokens with most providers). Reserved = 1,500 input × $0.00003 + 500 output × $0.00012 = $0.045 + $0.06 = $0.105. The call actually completes after only 310 output tokens: actual = 1,500 × $0.00003 + 310 × $0.00012 = $0.045 + $0.0372 = $0.0822. The ledger finalizes at $0.0822 and releases $0.0228 of reserved budget — exactly the value of the 190 unused output tokens at the output rate — back to the tenant's quota.

What is atomic, and inside which boundary

Atomicity here is per store, never across stores. Inside the quota store, one Lua script on one shard (counters and reservation record share a hash tag so they live on the same shard) performs each of these as a single indivisible step: reserve (check RPM, TPM and budget, decrement all, create the record), settle (check status, mark SETTLED, refund the unused part), and release (check status, mark RELEASED, refund everything). Inside the ledger, one idempotent insert keyed by reservation ID is atomic. No transaction spans the ledger and the quota store. Consistency between them comes from three things instead: a fixed order of operations (ledger first, then quota store), status checks that make every quota-store step safe to repeat, and a reconciler that repairs the gap if the gateway dies between the two. The ledger is the system of record for money; the quota counters are fast enforcement state that can be corrected from it.

Crash recovery and reconciliation

Consider each point where a gateway replica can die. Before reserve completes: nothing was held, nothing to repair. After reserve, before any upstream call: the reservation expires, and because no attempt was ever recorded the reconciler releases it in full. After an attempt was recorded and before usage is known: the provider may have run it, so the reconciler does not release; it looks up the provider's usage report by the correlation ID and settles from that, and if no report arrives before a hard ceiling it escalates, leaving the worst case charged rather than quietly refunding it. After the ledger insert and before the quota-store settle: the reservation is still RESERVED while the ledger already holds the usage, so the reconciler finds the ledger row and runs the same settle script. Double charge is prevented because the ledger insert is unique on the reservation ID; double release is prevented because settle and release only act on a RESERVED reservation, so a second attempt finds a terminal status and changes nothing.

Retrying after a timeout the provider may have executed

A timeout does not mean the provider did nothing. It means the gateway stopped waiting. So the attempt is recorded as unknown usage, and a second attempt is allowed only if the quota store can extend the same reservation to cover a possible second execution; otherwise the gateway stops and returns an error instead of risking uncovered spend. Where the provider supports an idempotency key, the retry sends the same key so a duplicate execution is not started; where it does not, a retry can produce two generations and two charges, and both are accounted against the reservation. Only one response is ever delivered to the client. An outright refusal (429, 5xx, connection refused) is different: the provider reports that it did not run the request, so the retry costs nothing extra and uses the existing reservation as is. Even so, providers' billing behavior differs, so the reconciler compares final provider usage with the gateway's records and flags a mismatch.

FieldSet atMutable after?
estimatedTokensadmissionno — historical record of what was reserved
actualTokensfinalizationno — set once, from provider usage report
reservationStatusadmission → finalization/expiryyes — status and the attempt list change, by design
priceVersionadmissionno — locked to avoid retroactive cost changes
Chapter 05

Model Registry & Logical Models

The model registry is a policy object store, versioned and distributed through the control plane — not a model file, and not a live lookup against any provider's own model-listing API.

Logical model record

{ name: 'support-fast', deployments: [{provider:'A', model:'a-chat-v3', region:'us-east-1', capabilities:{streaming:true, tools:true, jsonMode:true, maxContext:128000}, priceVersion:'2026-10'}, {provider:'B', model:'b-chat-2', region:'eu-west-1', capabilities:{streaming:true, tools:true, jsonMode:false, maxContext:96000}, priceVersion:'2026-10'}] }

Capability documentation is part of the contract

'support-fast supports streaming' is only true if every deployment behind it supports streaming — the registry must document capability intersection, not just union, or callers will intermittently get capability-mismatch errors depending purely on which deployment the router happened to pick that time.

Deprecation and migration

Deprecating a specific provider model (not the logical name) is transparent to clients: remove or down-weight that deployment from the logical model's deployment list in a new control-plane snapshot. Clients calling 'support-fast' never change their code. This is the entire point of the indirection introduced in Complete Design Chapter 07.

Chapter 06

Provider Adapters & Capability Contracts

An adapter is a typed translation layer: gateway-standard request/response shape on one side, a specific provider's native API shape on the other — with an explicit rejection path for anything it can't honestly translate.

Swipe to pan
Provider Capability MatrixA shared request shape, with real, documented boundaries underneathCapabilityProvider A (us-east-1)Provider B (eu-west-1)Streaming✓✓Tool calls✓✓JSON mode / structured output✓✗ → adapter rejects explicitlyContext length128K96KData residencyUS onlyEU-compliant
A shared request shape with real, documented capability boundaries per provider
textAdapter contract (conceptual interface)
interface ProviderAdapter:
    translateRequest(req: GatewayRequest, deployment) -> ProviderRequest
        # raises CapabilityUnsupported(field) if req needs something
        # this deployment cannot honor — never silently drops a field

    call(deployment, providerReq, stream: bool) -> ProviderResponse | AsyncIterator<Chunk>

    translateChunk(chunk: ProviderChunk) -> GatewayChunk
        # normalizes SSE/delta formats across providers to one chunk shape

    extractUsage(response | finalChunk) -> {inputTokens, outputTokens, finishReason}
        # the only source of truth for actual usage — never derived from
        # the gateway's own token-counting of the request/response text

Streaming chunk normalization

Different providers emit different SSE event shapes (delta-text vs. full-message-so-far, different field names for the finish reason, different tool-call streaming protocols). The adapter's translateChunk is where this gets normalized into one gateway-wide chunk shape, so the client-facing stream format never changes when a provider changes behind the scenes.

Usage extraction is provider-authoritative

Actual token usage always comes from the provider's own usage report in its response (or final streaming event), never computed by re-tokenizing the prompt and completion on the gateway side — different providers use different tokenizers, and a locally recomputed number would silently diverge from what the tenant is actually billed for (Invariant I7).

Chapter 07

Routing: Filter and Score

Routing is a two-stage function: a hard filter that can only remove candidates, followed by a weighted score that can only rank the survivors.

Swipe to pan
Router: Filter First, Score SecondHard constraints eliminate candidates before price/latency ever matterCandidate DeploymentsFilter: residency, capability, policyDisqualifiedexcluded before scoringEligible setscore: price, latency, healthHighest-scored eligible deployment
Filter on hard constraints first — residency, capability, policy — then score the survivors
textroute(request, candidates, policy)
function route(req, candidates, policy):
    eligible = []
    for d in candidates:
        if not d.supportsResidency(req.tenant.requiredRegion): continue
        if not d.supportsCapabilities(req.capabilities):        continue
        if d.circuitState == OPEN:                              continue
        if not policy.vendorAllowed(d.provider):                continue
        eligible.append(d)

    if eligible.isEmpty(): return NO_ELIGIBLE_DEPLOYMENT

    score(d) = policy.wPrice   * normalize(1 / d.pricePerToken)
             + policy.wLatency * normalize(1 / d.p50LatencyMs)
             + policy.wHealth  * d.healthScore          # 0..1, degrades on recent errors
             + policy.wCapacity * d.availableCapacityRatio

    return argmax(eligible, score)

Why filter can only remove, never add

If scoring ran first and filtering second, a disqualified deployment could theoretically win the score and only get caught afterward — exactly the ordering bug that causes a residency violation to reach a provider before anyone notices. Filtering first makes the disqualification a pre-condition of entering scoring at all, not a post-hoc check.

Health score vs. circuit breaker state

These are two different signals operating at two different granularities. Circuit state is binary-ish (closed/open/half-open) and gates eligibility entirely. Health score is continuous (0..1, based on recent error rate and latency percentile) and only affects ranking among still-eligible deployments — a deployment can have a mediocre health score and still be selected if every alternative is worse.

Chapter 08

Circuit Breakers & Retry Budgets

Two independent mechanisms protect against a struggling upstream: a per-request retry budget (protects the caller's own latency) and a per-deployment circuit breaker (protects the struggling dependency from more load).

Retry budget

A single request gets a bounded number of upstream attempts (commonly one primary plus one or two alternates, with jittered backoff — see Retry & Backoff) and a total deadline that covers every attempt, so retries can never stretch the client's wait without limit. Failover is allowed only while no bytes have been committed to the client. A provider's Retry-After header is honored verbatim when present; otherwise backoff starts at a small base delay with full jitter. All attempts run under the one reservation made at admission; the next sections explain what that means for accounting when an attempt times out.

Circuit breaker state machine

CLOSED (normal — requests flow, failures counted in a rolling window) → OPEN (≥N consecutive or ≥X% failures — all requests to this deployment are skipped immediately, no network call attempted, for a cooldown period) → HALF-OPEN (cooldown elapsed — exactly one probe request is allowed through) → back to CLOSED on probe success, or OPEN again (with the cooldown possibly extended) on probe failure. See Circuit Breaker for the general pattern.

Per-deployment, not per-provider

Circuit state is tracked per deployment (a specific model + region + provider combination), not per provider company — Provider A's us-east-1 deployment can be open while Provider A's eu-west-1 deployment (if the tenant's policy even allows it) stays closed. Collapsing these would incorrectly take healthy capacity offline.

Chapter 09

Streaming Architecture & Backpressure

Streaming turns the gateway into a long-lived, stateful-per-connection relay for the duration of one request — which changes resource accounting and failure handling compared to a one-shot request/response.

Swipe to pan
Streaming Failure SemanticsPre-first-byte failures can fail over silently. Post-first-byte failures cannot.CONNECTINGSTREAMINGchunks flowingCOMPLETEusage finalizedFAILED MID-STREAMexplicit error, marked incompleteCLIENT CANCELLEDreconcile, don't assume $0Once output is committed, a retry cannot splice in a different generation (Invariant I4)
CONNECTING → STREAMING → {COMPLETE, FAILED MID-STREAM, CLIENT CANCELLED}

Flow control, not unbounded buffering

The gateway reads from the upstream provider connection only as fast as it can write to the client connection (or an internal bounded buffer with a hard cap). When the client is slow, this naturally slows the read from upstream too — standard backpressure, the same mechanism as Backpressure in any producer/consumer system, applied specifically to SSE/chunked relaying.

Concurrency slot accounting

A streaming request holds a concurrency slot (and its token/budget reservation) for the entire stream duration, not just the initial handshake. Slow streams — by far the ~10% long-tail minority — disproportionately hold slots, which is exactly why admission control must reason in terms of concurrent in-flight load (Little's Law), not just requests-per-second.

Partial-failure event contract

A mid-stream failure emits a distinct stream_error event type to the client (not a reused 'chunk' event with an error-shaped payload) carrying: chunks delivered so far, a reason code, and incomplete: true. This lets client UIs render 'response was cut off' affordances instead of silently truncating or retrying automatically in a way that could duplicate tool-call side effects.

Cancellation propagation

On client disconnect, the gateway closes its own upstream connection where the provider's protocol supports cooperative cancellation, and always releases the concurrency slot immediately regardless of whether the upstream cancel succeeds cleanly. The budget reservation is not released: it is marked PENDING_RECONCILIATION and stays charged at its worst case until the provider's usage report (which may arrive after the cancel) confirms the actual generated amount, at which point the normal ledger-then-settle sequence runs. A failure after the first byte follows the same path with whatever usage the provider reported.

Chapter 10

Control Plane / Data Plane Separation

The data plane (gateways) never performs a synchronous call to the control plane inside a request's critical path. All policy, pricing, and credential changes arrive asynchronously, as versioned snapshots.

Swipe to pan
Control Plane / Data Plane SeparationPolicy changes rarely; traffic arrives constantly. Different latency budgets, different systems.Gateway gw1Gateway gw2Gateway gw3Control Planepolicy · pricing · credentialsversioned snapshot push (v42)Never a live admin-DB query inside a per-request hot path
Versioned snapshot push — never a per-request admin-DB query

Snapshot versioning

Each policy snapshot carries a monotonically increasing version number. A gateway logs which version it's operating on with every request's telemetry — so an incident review can answer 'was this request affected by the policy bug introduced in v42' precisely, instead of guessing from timestamps.

Freshness window and its security trade-off

Gateways poll for (or receive a push of) the latest snapshot on an interval short enough to be operationally acceptable — commonly tens of seconds. A permission revoked between snapshots is honored only once the next snapshot lands; this is a deliberate, bounded staleness traded for removing the control plane from the hot path entirely. The bound must be documented and short enough for the actual threat model, not assumed to be zero.

Outage behavior

If the control plane is unreachable, gateways continue serving on their last successfully loaded snapshot indefinitely (or until an operator-configured max-staleness kill-switch, if one exists for the deployment). New tenant onboarding and policy changes are blocked until the control plane recovers; existing traffic is unaffected.

Chapter 11

Error Status Semantics

Every status code the gateway returns carries a specific, consistent meaning a client can program against — mixing these up produces integration bugs that are hard to diagnose.

StatusMeaningClient should
401Invalid or missing virtual keyRefresh/re-issue the credential; do not retry the same request as-is
403Valid credential, disallowed model/capabilityDo not retry — this is a policy decision, not a transient failure
429 (gateway)Tenant's own RPM/TPM/budget exhaustedHonor Retry-After; back off per-tenant, not globally
429 (relayed from provider)The provider's own aggregate limit was hit — can still happen even with per-tenant admission control, if the gateway's total outbound traffic across all tenants exceeds what the provider allots this accountRetry through the router against a different provider/deployment if one is eligible; otherwise back off and surface a capacity signal, not an auth error
503No eligible deployment (all filtered out or circuits open)Retry later with backoff; this is a capacity/compliance condition, not an auth problem
stream_error eventMid-stream upstream failure after bytes committedRender partial response as incomplete; do not auto-retry destructively