Skip to content

fix: reasoning headroom and zero-content fail-closed settlement on the free pool - #1225

Merged
sakibsadmanshajib merged 1 commit into
mainfrom
fix/freepool-headroom
Aug 26, 2026
Merged

sakibsadmanshajib merged 1 commit into
mainfrom
fix/freepool-headroom

Conversation

@sakibsadmanshajib

Copy link
Copy Markdown
Owner

Fixes #1171

Root cause

The hive-free alias load-balances four heterogeneous members behind one litellm_model_name. Two of them reason; a reasoning member can spend the caller's entire max_tokens on hidden reasoning and answer finish_reason=length with no visible content, which settled as an ordinary full-price success. Live evidence on #1171: five of six reasoning prompts returned empty content and were billed in full.

Fix

1. Per-member headroom (pre-dispatch)

  • New column provider_routes.reasoning_reserve_tokens (migration 20260826_01_route_reasoning_reserve.sql). The three reasoning members (route-free-pool-gemini, route-free-pool-groq, route-free-pool-groq-2) carry 4096; the non-reasoning OpenRouter dots member stays at 0, so its deployments keep exactly the ceiling the caller asked for.
  • routing.SelectionResult carries the MAX reserve across eligible routes sharing the selected litellm_model_name (LiteLLM load-balances that whole group under one name, so edge-api cannot know which member answers before dispatch). Candidates on other gateway groups are excluded.
  • edge-api/internal/inference.applyReasoningHeadroom inflates every completion-limit field the caller actually set (chat: both max_tokens and max_completion_tokens; completions: max_tokens; responses: max_output_tokens) by that reserve on all dispatch paths (sync chat/completions/responses, streaming chat/responses). A ceiling field absent or non-positive is left alone: a caller who set no ceiling declared no budget to protect.
  • The caller's requested max_tokens keeps its OpenAI meaning: it caps what they SEE.

2. Zero-content guard (sync chat path)

Doctrine order followed: retry once first, capture only when the retry does not produce content.

  • An all-empty finish_reason=length completion on a reserving pool is retried once against the same pool (LiteLLM's shuffle makes a repeat member pick possible but not guaranteed, documented honestly).
  • If the retry is empty or fails too: settle fail-closed by capturing the reservation hold at reservation.Held() with terminal_usage_confirmed=false, exactly the capture shape PR fix: streaming settlement fails closed, captures the reservation hold when upstream usage is missing #1220 established for streams, plus:
    • alarm log marker zero_content_captured and a new Prometheus counter hive_zero_content_captured_total{alias,endpoint},
    • honest flag to the caller: X-Hive-Upstream-Empty-Content: length response header alongside the upstream body whose finish_reason already explains the emptiness.
  • Deliberately narrow detector: finish_reason=stop empties are genuine answers and not captured; tool-call messages with null content are spec-correct and never trip the guard; refusals count as visible output.

Scoped to the sync path where the live evidence sits; the streaming path gets headroom only, since its settlement side is #1220's freshly merged territory and a mid-stream retry after chunks flowed is not possible.

3. Metering nondeterminism (findings-only)

Identical prompts metered 3 vs 76 prompt tokens across members is upstream variance, not a gateway bug: the four members run different tokenizers and counting rules (gpt-oss harmony BPE vs Gemini SentencePiece-family counting vs the dots model), and LiteLLM may estimate locally for members whose upstream omits usage. Correcting it at our normalize boundary would mean shipping tokenizers or second-guessing provider-reported usage, which settlement treats as truth. Billing flows through alias price x reported tokens, so the variance reaches the customer by design of the free lane. No code change made; revisit only if cross-member variance ever matters on a paid heterogeneous pool.

Test evidence

Red-then-green per requirement:

  • Offline selection guards: pool-max aggregate, cross-group exclusion, zero-reserve default, migration targets exactly the right members (reasoning_reserve_test.go).
  • Headroom units: pass-through cases and inflation of one/both chat spellings (zero_content_guard_test.go).
  • Orchestrator: retry-succeeds (2 hits, normal confirmed settlement), twice-empty (2 hits, capture at confirmed hold 10000, header set, terminal_usage_confirmed=false), non-reserving alias never retries (1 hit).
  • Live Postgres: throwaway pgvector/pg17 container bootstrapped like CI (.github/ci/test-db-bootstrap.sql + full migration chain applied in order, including the new migration), integration-tagged routing suite green including the two new live tests (TestFreePoolReasoningReserveLiveRows, TestFreePoolReasoningReserveLiveSelection) proving the real schema, seeded rows, pgx scan, and aggregate end to end. CI runs this same suite against its ephemeral DB.
  • Full suites via docker toolchain post-rebase onto main (incl. fix: streaming settlement fails closed, captures the reservation hold when upstream usage is missing #1220, fix: add prompt-cache endpoint affinity to the DeepSeek OpenRouter routes #1221): control-plane + edge-api, 83 packages ok, 0 FAIL. go vet -tags integration clean.

Notes

Buglog entry

To be appended to .wolf/buglog.jsonl in the follow-up buglog-only PR once this merges (#873 protocol):

{"id":"1171-zero-content-full-price","ts":"2026-08-26","error_message":"5 of 6 reasoning prompts on hive-free returned finish_reason=length with empty content and settled as ordinary full-price successes; identical prompts also metered 3 vs 76 prompt tokens across pool members","root_cause":"load-balanced heterogeneous pool dispatched the caller's max_tokens verbatim, letting reasoning members spend the whole visible budget on hidden reasoning; settlement had no zero-content verdict and trusted provider-reported usage unconditionally","fix":"per-member reasoning_reserve_tokens inflated into the upstream completion ceiling before dispatch (pool-max surfaced through SelectRoute); sync chat completions retry an empty length-finish once then capture the reservation hold with terminal_usage_confirmed=false plus hive_zero_content_captured_total counter and X-Hive-Upstream-Empty-Content header","tags":["billing","free-pool","reasoning","fail-closed","litellm"]}

…ement on pooled aliases (#1171)

Reasoning members of a load-balanced pool can spend the caller's entire
max_tokens on hidden reasoning and answer finish_reason=length with no
visible content, which settled as an ordinary full-price success. Two
mechanisms close that.

Headroom: provider_routes gains reasoning_reserve_tokens (migration
20260826_01); the three reasoning members of the hive-free pool carry
4096, the non-reasoning dots member stays at 0. SelectRoute surfaces the
MAX reserve across eligible routes sharing the selected litellm_model_name,
and edge-api inflates the completion-limit fields it dispatches upstream by
that figure on every endpoint path, so visible content survives inside the
caller's own budget.

Zero-content guard (sync chat): an empty finish_reason=length completion is
retried once against the same pool; if the retry is empty or fails too, the
request settles fail-closed by capturing the reservation hold with
terminal_usage_confirmed=false (the capture shape #1220 established for
streams), raises hive_zero_content_captured_total, and sets the
X-Hive-Upstream-Empty-Content response header so no SDK client is left
guessing why content is empty.

Metering nondeterminism investigated; findings-only in the PR body.

Tests: offline selection guards, headroom units, orchestrator
retry-success / capture / scope tests, live-Postgres-gated tests over the
real seeded rows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 54 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a8daf04-0724-41aa-b8cd-19061482352e

📥 Commits

Reviewing files that changed from the base of the PR and between a699ed1 and 3bcf7fb.

⛔ Files ignored due to path filters (1)
  • go.work.sum is excluded by !**/*.sum
📒 Files selected for processing (13)
  • apps/control-plane/internal/routing/reasoning_reserve_integration_test.go
  • apps/control-plane/internal/routing/reasoning_reserve_test.go
  • apps/control-plane/internal/routing/repository.go
  • apps/control-plane/internal/routing/service.go
  • apps/control-plane/internal/routing/types.go
  • apps/edge-api/internal/inference/orchestrator.go
  • apps/edge-api/internal/inference/reasoning_headroom.go
  • apps/edge-api/internal/inference/routing_client.go
  • apps/edge-api/internal/inference/stage_metrics.go
  • apps/edge-api/internal/inference/stream.go
  • apps/edge-api/internal/inference/stream_responses.go
  • apps/edge-api/internal/inference/zero_content_guard_test.go
  • supabase/migrations/20260826_01_route_reasoning_reserve.sql

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sakibsadmanshajib
sakibsadmanshajib merged commit 3bc5b37 into main Aug 26, 2026
26 checks passed
@sakibsadmanshajib
sakibsadmanshajib deleted the fix/freepool-headroom branch August 26, 2026 09:40
sakibsadmanshajib added a commit that referenced this pull request Aug 26, 2026
…ee-lane timeouts (#1230)

## What

The deploy SDK replay test `model field shows Hive alias not provider
handle` timed out at 60s on the free lane. Since #1225 the free pool
retries once on empty content and reasoning members inflate token
budgets, so worst-case free-lane latency now exceeds vitest's default
60s per-test timeout (the sibling free-lane test passed at 26.5s).

## Change

- The alias-echo test's purpose is the alias echo, not a free-lane
exercise, so it now runs on `HIVE_TOOLS_MODEL` (default
`deepseek-v4-flash`), mirroring the tools and response_format tests in
the same file.
- The one remaining test that intentionally uses the free `MODEL` gets
an explicit `{ timeout: 120000 }` so future latency growth fails on
assertion, not on the runner default.
- Assertions are identical; no behavior change to what is verified.

## Test plan

- [x] `tsc --noEmit -p tsconfig.json` on `packages/sdk-tests/js`: no new
errors (the single pre-existing TS2339 on main reproduces identically,
shifted lines only)
- [ ] Live replay run against the deployed gateway (requires live stack;
suite is deploy-gated)

## Buglog entry

None: no bug fixed in shipped code, this is test-infrastructure
retargeting.
sakibsadmanshajib added a commit that referenced this pull request Aug 26, 2026
…DeepSeek post-finish chunk (#1222)

## Summary

Provider identity was leaking to customers on both
`/v1/chat/completions` and `/v1/messages` (Anthropic-compat), streaming
and non-streaming, on both OpenRouter-routed (DeepSeek) and Groq-routed
aliases. An upstream response id format is provider-identifying by
construction (`gen-*` = OpenRouter, `chatcmpl-*` with Groq's own suffix
scheme = Groq), the same class of leak CLAUDE.md's provider-blind
invariant forbids for error strings. Groq responses additionally carried
a raw `system_fingerprint` field. This closes both leaks at every
normalize boundary that builds a customer-facing response, and fixes a
related DeepSeek stream defect in the same relay code.

Evidence: `/tmp/reports/parity-inhive.md` (finding "Wire cosmetics",
live parity run 2026-08-26) plus a fresh live negative-control capture
against `api-hive.scubed.co` taken during this fix (see Verification
below), reproducing the exact defect moments before the fix.

## What leaked and where

- **`/v1/chat/completions` non-streaming** (`normalizeChatCompletion`):
forwarded `resp.ID` verbatim.
- **`/v1/chat/completions` streaming** (`executeStreaming` SSE relay):
every chunk carried the upstream's own id, not just the terminal frame;
`system_fingerprint` was never stripped.
- **`/v1/completions` (legacy) non-streaming** (`normalizeCompletion`):
same `resp.ID` passthrough.
- **`/v1/messages` (Anthropic-compat) non-streaming**
(`FromOAIResponse`): prefixed the raw upstream id with `msg_` instead of
minting a fresh one, still shipping the upstream id inside the prefix.
- **`/v1/messages` streaming** (`SSETranslator.FeedLine`): same
prefix-not-mint bug for `message_start.id`, reused (correctly) for the
rest of the stream but wrong at the source.
- **DeepSeek-family streams** (both surfaces relay through the same
OpenAI-shaped chunk): OpenRouter emits one extra empty `role`/`content`
chunk immediately after the real `finish_reason` frame, before `[DONE]`.
A strict SSE client that already closed the message on the real finish
frame can choke on it.

## Fix

- New `mintCompletionID(prefix)`
(`apps/edge-api/internal/inference/mint_id.go`) mints a gateway-owned id
(`chatcmpl-<uuid>` / `cmpl-<uuid>`), used by every OpenAI-shaped
normalize site. `idPrefixForEndpoint` picks the right prefix per
endpoint so a stream's minted id matches its non-streaming twin's shape.
- `normalizeChatCompletion` and `normalizeCompletion` mint a fresh id
and strip `system_fingerprint`; the original upstream id is kept in a
local variable purely for the existing usage-clamp log line, never for
anything client-facing.
- `executeStreaming`'s relay loop mints **once per stream** and reuses
that same id on every chunk, including the synthesized terminal usage
chunk, so a client-visible id is stable across the whole response
(matches its non-streaming twin's shape too).
- `FromOAIResponse` and `SSETranslator.FeedLine` (Anthropic-compat) now
always mint a fresh `msg_<uuid>`, never derive from the upstream
chunk/response id.
- `SanitizeVariablePriceFrame` (the map-based fallback sanitizer, shared
by the streaming relay's rare unparseable-chunk path and
`apps/edge-api/internal/chat`'s own verbatim relay) now also drops `id`
and `system_fingerprint` as defense in depth, since it has no per-stream
state to mint a stable replacement from.
- New `shouldSuppressPostFinishChunk` / `chunkFinished` (pure,
unit-tested) gate the DeepSeek post-finish chunk: any chunk arriving
after a relayed `finish_reason` is dropped from the wire unless it
carries `usage` (the legitimate `stream_options.include_usage` terminal
frame, which always forwards). The suppressed chunk's content is still
folded into the usage accumulator first, so billing never silently loses
data, only the wire write is skipped.

## What this does NOT change (verified explicitly)

1. **Internal correlation.** Nothing reads `resp.ID` / `chunk.ID` for
request or attempt correlation. That keys entirely on `attempt.ID`, a
UUID this gateway mints independently at dispatch time
(`orchestrator.go`/`stream.go` `StartAttempt`), completely decoupled
from any upstream or client-facing response id, before and after this
change. Confirmed by reading every caller of
`RequestAttemptID`/`AttemptID` across `edge-api` and `control-plane`;
none of them touch the response body's `id` field.
2. **Billing/settlement.** Settlement (`settleStream`, `ChargeUsage`,
reservation release) also keys on `attempt.ID`/`reservation.ID`, never
on the response id. The usage-clamp log line is the only place the *old*
upstream id was ever read after this change, and it still gets it (via a
locally kept `upstreamID`/`upstreamChunkID` variable), so diagnostic log
fidelity is unchanged.
3. **Streaming usage semantics.** `NormalizeCacheUsage`'s wire-shape
selection (`usage.CacheReadInputTokens != nil`) is untouched; no
model-family branch was added anywhere in this diff.
4. **Id stability.** A minted id is generated once per response/stream
and reused on every chunk (`mintedID` in `executeStreaming`,
`t.messageID` in `SSETranslator`), and the OpenAI-shaped id prefix
matches its endpoint's non-streaming shape.

## Verification

**Unit tests** (new `mint_id_test.go`, updated
`translate_response_test.go`/`stream_test.go` in `internal/anthropic`,
updated `upstream_cost_hardening_test.go`):
- Non-streaming mint + strip for both an OpenRouter `gen-*` fixture and
a Groq `chatcmpl-*` + `system_fingerprint` fixture, asserting the minted
id never equals the upstream id and the wire bytes contain neither the
upstream id string nor `system_fingerprint`.
- Legacy `/v1/completions` mint.
- Anthropic non-streaming and streaming: minted id always starts with
`msg_`, never equals or contains the upstream id, even when the upstream
id already looks `msg_`-shaped.
- `shouldSuppressPostFinishChunk`/`chunkFinished` against the **exact
byte-for-byte chunk sequence captured live** on `api-hive.scubed.co`
(see below): the real finish chunk forwards, the spurious trailing chunk
suppresses, a genuine usage-only terminal chunk still forwards.
- Full `go test ./apps/edge-api/...` passes (all packages green), run
from this worktree's own `deploy/docker` (not the shared checkout, to
avoid a stale-image false green).

**Live negative control**, captured against the deployed (pre-fix) box
moments before this fix, using the existing parity test key
(`api_keys.id c4bc000e-3861-4941-8259-68714c1b6d47`, no rotation):
- Non-stream `deepseek-v4-flash`:
`"id":"gen-1787734315-Jo86gvcpmVkDttYHFhKc"` forwarded verbatim.
- Non-stream `hive-free`: `"id":"gen-1787734317-qUFgWPoJsd4J75lctb2B"`
forwarded verbatim (this pool member happened to route through a `gen-*`
upstream on this call).
- Stream `deepseek-v4-flash`: every one of 5 chunks carries the
identical upstream id `gen-1787734329-THDkSN71nG5F8uAbPdnB`, AND the
exact spurious post-finish chunk reproduces:
`{"delta":{"role":"assistant","content":""},"finish_reason":null}`
arrives immediately after the `"finish_reason":"length"` chunk, before
`[DONE]`.

This confirms the defect is real and live, and that the fix (verified in
isolation by the unit tests above, against these same captured bytes)
targets the actual bug.

**What I could not do as builder**: deploy is orchestrator-only (this
repo's coding pipeline, stage 10) since a push to `main` auto-deploys
the live box. I have not verified the fixed code against the live box
post-merge; that is the orchestrator's job at the deploy-confirmation
step. The unit tests above exercise the real
`mintCompletionID`/`shouldSuppressPostFinishChunk`/`chunkFinished`/`FromOAIResponse`/`SSETranslator`
functions directly (not a reimplementation), including against the
live-captured byte sequence, so I'm confident in the fix, but I want
that boundary stated plainly rather than implied.

## Known residual (found, not fixed here — flagging per project
convention rather than burying it)

`apps/edge-api/internal/chat/dispatch.go` (the Open WebUI session-chat
relay, a separate customer-facing surface from the OpenAI-compat
`/v1/chat/completions` this PR fixes) has the same class of leak and is
**not touched by this PR**:
- For a variable-price alias, it already runs every frame through
`SanitizeVariablePriceFrame`, so this PR's `id`/`system_fingerprint`
deletion there also happens to close the leak on that path as a side
effect.
- For a **fixed-price alias** (e.g. Groq/`hive-free`), the relay is `_,
_ = w.Write(line)` — genuinely verbatim, no sanitization of any kind,
not even the model field. This is a live leak on the primary chat UI
surface today.

I did not touch `internal/chat` in this PR: it's a different package
with its own settlement logic and no verification plan was scoped for it
here, and I did not want to expand a security-sensitive diff's blast
radius past what was asked and verified. Recommend a dedicated follow-up
PR/issue scoped to that file specifically.

## Buglog entry

See the commit message on this branch's `HEAD` for the full JSON line
(heading "Buglog entry"), to be appended to `.wolf/buglog.jsonl` on
`main` per this repo's convention (never on a feature branch).


---

## Update: security review round 2 (blocking findings addressed, rebased
onto main)

Rebased onto `origin/main` (27a4b31, twelve PRs merged while this
branch was open, including #1220's streaming fail-closed settlement fix,
#1221 cache affinity, #1225 free-pool headroom). Clean merge, no
conflicts; full `edge-api` and `control-plane` suites green post-merge
before any of the fixes below were applied.

### Blocking finding 1: `stream.go`'s second verbatim fallback — FIXED

When a chunk failed typed decode AND the route was fixed-price, the raw
upstream line shipped unsanitized (id, `system_fingerprint`,
everything), with no log line. Fixed by collapsing both pricing branches
onto the one map-based sanitizer (`SanitizeVariablePriceFrame`) the
variable-price case already used: mint the id, strip
`system_fingerprint`, drop-and-log anything unparseable, for every
route. Verified by full diff review (single call site now, not two) plus
the existing `SanitizeVariablePriceFrame` unit test suite, which covers
exactly this shape. I did not build a new end-to-end test reaching this
specific fallback inside `executeStreaming`: no test in this codebase
currently drives that function end to end (confirmed by grep), and
building that harness from scratch is a disproportionate lift for this
fix; the shared sanitizer this fallback now unconditionally calls is
what's under test.

### Blocking finding 2: `apps/edge-api/internal/chat/dispatch.go`
fixed-price branch — FIXED

Every SSE line was written raw with zero sanitization on the fixed-price
branch (D-032 norm, so most aliases, the primary Open WebUI chat
surface). Every data frame now runs through the same sanitizer,
fixed-price included, with a per-stream minted id threaded through so a
client sees one stable id across the whole stream. New test
`TestDispatchFixedPriceStreamSanitizesUpstreamID` exercises the real
`dispatch.ServeHTTP` end to end with a `FixedPricing` route and a
fixture carrying `id`+`system_fingerprint` in the exact shape the live
leak had.

**Negative control, both findings**: stashed only the fix source files
(kept the new/updated tests in place) and re-ran them against the
pre-fix code. Both failed exactly as expected, reproducing the leak
verbatim (`chatcmpl-8f3a9c2e1b4d` + `system_fingerprint` leaked on the
dispatch.go test; the RAG test below leaked `up-1` with no minted id at
all). Restored the fix, re-ran, both pass.

### Third leak, found while auditing per the review's instruction to
assume one exists

`apps/edge-api/internal/rag/chat_handler.go`'s `streamGroundedChat` (the
`/v1/rag/chat` streaming path) relays chunks through a generic
`map[string]any`, not a typed struct. Its own comments already state a
"provider-blind" design intent, and it does drop provider names and
`event:` lines, but nothing stripped `id` or `system_fingerprint` from
the surviving map keys, so both leaked on every chunk. Fixed with the
same mint-once-per-stream, strip-system_fingerprint treatment.
Strengthened the existing
`TestHandleChat_StreamingRelaysCitationsAndChunks` test (its fixture
already carried `"id":"up-1"` on every chunk, but nothing asserted
against it) to check the raw upstream id never appears and the same
minted id repeats across both chunks. Same stash-based negative control
applied and confirmed (see above).

### Full enumeration of every response-emitting path (as requested, so
the next reader doesn't have to re-derive it)

| Path | Sanitization status |
|---|---|
| `/v1/chat/completions` non-stream (`normalizeChatCompletion`) |
SANITIZED — mint id, strip `system_fingerprint` (commit 1) |
| `/v1/chat/completions` stream, typed-decode success
(`executeStreaming` primary relay) | SANITIZED — mint once per stream,
strip per chunk, post-finish suppression (commit 1, tightened per
CodeRabbit) |
| `/v1/chat/completions` stream, typed-decode FAILURE fallback |
SANITIZED — unified onto the same sanitizer as above (commit 2, blocking
finding 1) |
| `/v1/chat/completions` stream, synthesized terminal usage chunk
(upstream never sent usage) | SAFE by construction — gateway-built,
never touches upstream bytes; id field updated for stability only,
pre-existing logic otherwise untouched |
| Legacy `/v1/completions` non-stream (`normalizeCompletion`) |
SANITIZED — mint id (commit 1) |
| Legacy `/v1/completions` stream | Same code path as chat completions
stream (shared `executeStreaming`) — SANITIZED |
| `/v1/responses` (OpenAI Responses API) | ALREADY SAFE pre-PR — mints
its own `resp_` id independently, confirmed clean by security review,
untouched |
| `/v1/messages` (Anthropic-compat) non-stream (`FromOAIResponse`) |
SANITIZED — always mints fresh `msg_` id, old code only prefixed the
upstream id (commit 1) |
| `/v1/messages` (Anthropic-compat) stream (`SSETranslator`) | SANITIZED
— same fix, never relays raw bytes at all by construction (commit 1) |
| `/v1/rag/chat` non-stream | ALREADY SAFE pre-PR — mints its own
`ragchat-` id independently (existing code, confirmed while auditing) |
| `/v1/rag/chat` stream (`streamGroundedChat`) | SANITIZED — third leak
found this round, fixed (commit 2) |
| OWUI session chat (JWT-authenticated `/v1/chat/completions`),
variable-price branch | SANITIZED — already routed through the shared
sanitizer, now with a stable minted id (commit 1) |
| OWUI session chat, fixed-price branch | SANITIZED — blocking finding
2, fixed this round (commit 2) |
| Non-data SSE lines (blank, `event:`, `[DONE]`) in any relay | SAFE by
construction — no id/model/cost/provider fields on these lines |
| `/v1/embeddings`, audio transcription/translation, image generation |
SAFE by construction — none of these response types declare an `id` or
`system_fingerprint` field at all (checked each typed struct) |
| `/v1/batches` object metadata (`Batch.ID`) | SAFE — control-plane's
own minted internal id, never an upstream identity (edge-api's batches
client talks to control-plane, never to a provider directly) |
| `apps/control-plane/internal/batchstore` local batch executor output
file (`OutputLine.Response.Body`) | **NOT SANITIZED, known and
disclosed, not fixed in this PR** — raw upstream JSON forwarded verbatim
into the customer-downloadable batch output. Different service
(control-plane, not edge-api), different settlement/output path, no
verification plan scoped here. Recommend a dedicated follow-up PR/issue;
flagged loudly rather than silently left out of this enumeration. |

### Settlement/billing verification (three specific requirements from
the #1220 author)

1. **`HasForwardedChunk` on every forwarded frame, including the new
sanitized fallbacks.** Verified by reading every
`accumulator.HasForwardedChunk = true` call site in the diff: it is set
immediately before every actual client write (primary typed-relay
success, the newly-unified fallback's sanitized-success branch), and NOT
set on any drop. No site was removed; the only new site is on the
fallback's success path, paired 1:1 with its write.
2. **Missing-usage capture path stays `Held()`-full, plus alarm, plus
completed status; the minted path cannot skip it.**
`accumulator.Accumulate(chunk, aliasID)` and the
`ClampUsage`/`RawUsageChunk` capture run unconditionally, BEFORE the
post-finish suppression check (`if suppressPostFinish { continue }`). A
suppressed (non-forwarded) chunk still fully feeds the settlement
accumulator; suppression only skips the client-facing write.
`shouldSuppressPostFinishChunk` also explicitly exempts any real
usage-only terminal frame (usage set, zero choices) from suppression at
all, so the actual terminal usage chunk is never touched by this logic
in the first place.
3. **Synthesized terminal usage chunk — not new, not double-billing.**
The `includeUsage && !accumulator.HasUsage` synthesis block predates
every commit in this PR (present before commit 1). This PR's only change
to it is the `ID` field (now reuses the stream's `mintedID` for
id-stability rather than a fresh random uuid). The condition that
triggers it, and the usage numbers it carries
(`accumulator.ToUsageResponse()`), are untouched. This PR touches no
settlement arithmetic anywhere: every hunk in `stream.go`'s diff sits
inside the SSE relay loop or this synth-chunk block, none inside
`settleStream`/`settlementCredits` (confirmed by `git diff
origin/main...HEAD -- apps/edge-api/internal/inference/stream.go` hunk
ranges). The synthesized chunk is written to the client-facing wire
only; settlement's own usage numbers come from the `accumulator` fields
directly, never by re-reading what was written to the client, so there
is no read-back path for a future consumer (#1226) to double-count.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
…1305)

Closes #1283.

## What was broken

Both defects were confirmed against production with the real OpenAI SDK
v7.8.0 (workflow run 33209920195, 2026-08-28), so this is shipped
behaviour, not a theoretical gap.

1. **`max_tokens` was not enforced.** `max_tokens: 8` on `hive-free`
returned **1650 completion tokens**, and all 1650 were billed. The
caller asked for 8 to bound their own spend and was charged roughly 200
times that.
2. **`n: 2` returned a single choice** with HTTP 200 and no indication
the parameter had been dropped.

## The billing invariant, stated precisely

> **A request that specifies `max_tokens: N` must never be billed for
more than N completion tokens.**

Scope of the guarantee, so nobody has to re-derive it from the diff:

- It applies to every token-priced settlement branch on
`/v1/chat/completions`, `/v1/completions` and `/v1/responses`: the
measured charge, the content-estimate fallback, the sync zero-content
hold capture, and the streaming missing-usage hold capture.
- The ceiling is read from the caller's ORIGINAL body, before any
outbound rewrite. `EnforceVariablePriceBounds` forces a ceiling Hive
chose rather than one the caller asked for, so reading it afterwards
would bound the charge by our own number and guarantee the caller
nothing.
- With both chat spellings present the SMALLER is used. The request is
self-contradictory and no reading is provably what the caller meant, so
the tie goes to the number that keeps the invariant literally true for
whichever field they wrote. That under-charges Hive on a malformed
request, the direction every estimate on this path already errs in.
- Prompt tokens are never clamped. Only the completion side is bounded.
- The bound only ever lowers a charge and never to zero:
`CreditsForTokens` floors any non-zero quantity at one credit, so the
fail-closed rules in D-034 and D-048 (no served request bills zero) are
untouched.
- **Carve-out, stated rather than left to be discovered:** a
variable-price alias (`Pricing.IsUpstreamActual`) settles on the cost
the upstream reported for the generation, not a token count times a
catalog rate, so there is no per-token figure to cap. Its spend stays
bounded by `EnforceVariablePriceBounds` forcing the outbound ceiling to
`VariablePriceMaxCompletionTokens`, and by the hold clamp in
control-plane's `finalizeLocked`. No route with a nonzero reasoning
reserve is variable-priced today.

Arithmetic is unchanged and still runs through `metering.ChargeCredits`,
so every figure stays `math/big`. No float64 was introduced.

## Root cause of defect 1

`applyReasoningHeadroom`, shipped by PR #1225 for issue #1171 and live
since 2026-08-26, inflated every completion-limit field the caller had
set by `provider_routes.reasoning_reserve_tokens` before dispatch. That
column is 4096 on three of the four `hive-free` pool members, so
`max_tokens: 8` went upstream as `4104`.

Its premise was that hidden reasoning would burn the reserve while
visible content survived inside the caller's own budget. The premise is
not enforceable: nothing in an OpenAI-compatible request tells an
upstream that part of a ceiling is earmarked for hidden reasoning, so
the model spends the inflated ceiling on whichever it emits first. On
the reported request (`Write a long story about the sea.`) that was
visible story text.

The sharper finding is that the rewrite deliberately skipped a ceiling
the caller had NOT set. Its entire effect was therefore overriding
ceilings callers HAD set. It had no legitimate operating mode, so it is
deleted rather than tuned.

This was not a symptom patched at one call site.
`applyReasoningHeadroom` had exactly three callers (`orchestrator.go`
sync, `stream.go`, `stream_responses.go`); all three are gone, and the
settlement bound is applied at each path's single metering seam rather
than per branch.

## Root cause of defect 2

`N *int` was declared on both `ChatCompletionRequest` and
`CompletionRequest` and read by nothing. The raw body was forwarded to
LiteLLM, the parameter was ignored upstream, and one choice came back
with HTTP 200.

## Where enforcement lives, and why

**Both boundaries.** Either alone is a hope rather than a proof.

**Request boundary.** The caller's ceiling now reaches the provider
unchanged. This is the only boundary that can stop a caller receiving
1650 tokens they capped at 8, and it is what makes the reported usage
honest rather than merely cheap. It also restores OpenAI semantics,
where a reasoning model's hidden reasoning counts against the ceiling.

**Settlement boundary.** The metered completion count is capped at the
caller's own ceiling before anything reads it. It clamps the ONE usage
object both the customer response and the charge derive from, and
rewrites the already-marshalled body to match, so the number the caller
reads and the number they pay for cannot diverge. Request-boundary
correctness cannot carry the money guarantee on its own: it would depend
on every upstream honouring a parameter we merely forwarded, on no
future rewrite of the outbound body, and on no provider counting hidden
reasoning into `completion_tokens`. PR #1225 is the proof that the
outbound body only has to be wrong once.

**A settlement-only fix was also rejected.** The caller would still
receive and be shown a completion count they explicitly capped, and
every OpenAI-compatible cost dashboard reads `usage.completion_tokens`
unconditionally.

## What issue #1171 keeps, and what it loses

Its zero-content guard, the fail-closed half, is untouched: an
empty-content `finish_reason=length` completion on a reserving pool is
still retried once and still captures rather than settling full price.
`provider_routes.reasoning_reserve_tokens` keeps a reader as the flag
that gates it, so no column is orphaned.

What issue #1171 loses is the product half: a reasoning member given a
very small ceiling can again answer with empty content. That outcome is
now strictly cheaper than before this PR, because the capture that
follows it is bounded by the caller's ceiling instead of charging the
flat `DefaultHoldText` hold. Routing a small-ceiling request to a
non-reasoning member, or sending `reasoning_effort: low`, are the honest
ways to recover the product half and are follow-up work, not a silent
rewrite of the caller's request.

## Defect 2 decision: reject, not support

`n` other than 1 now returns 400 `unsupported_parameter` with `param:
"n"` on both `/v1/chat/completions` and `/v1/completions`. Absent and
`n: 1` pass through untouched; `n: 0` and negatives are refused too,
which OpenAI also rejects.

Rejecting rather than honouring, because no route in this catalog can
serve `n > 1`: the free pool's Groq members accept only `n=1` on their
OpenAI-compatible surface, and OpenRouter does not implement the
parameter across the pool. Honouring it would additionally multiply
generated tokens against a single per-request `max_tokens` ceiling,
which is the invariant above. Silently returning one choice was the only
unacceptable option, and a declared 400 is the OpenAI-contract-correct
answer for a parameter the upstream genuinely cannot serve.

The refusal message names the parameter and nothing about who serves the
request, so it stays provider-blind. `support-matrix.json` notes for
both endpoints now record the refusal and the settlement bound.

## Tests, written first and mutation-checked

Every behavioural test below was written before the fix and observed
failing against the unfixed code. Each fix was then reverted
individually to prove the test catches its absence rather than merely
its deletion, and restored.

| Mutation | What was reverted | Result |
| --- | --- | --- |
| A | reinstate the headroom inflation on the sync path | RED:
`TestExecuteSync_CallerCeilingReachesProviderUnchanged` reported
`dispatched max_tokens = 4104, want the caller's own 8` |
| B | remove the `clampUsageToCeiling` call sites in `executeSync` and
`executeStreaming`, leaving the helper intact so this tests the wiring
rather than the function | RED: both
`TestExecuteSync_BilledCompletionTokensCappedAtCallerCeiling` and
`TestExecuteStreaming_BilledCompletionTokensCappedAtCallerCeiling` |
| C | sync zero-content capture back to `reservation.Held()` | RED:
`TestExecuteSync_ZeroContentCaptureBoundedByCallerCeiling` reported
`charged 10000 credits against a ceiling worth 108` |
| D | `unsupportedChoiceCount` returns false | RED: all three
`TestNGreaterThanOneRejected` subtests |
| E | streaming missing-usage capture back to `reservation.Held()` |
RED: `TestExecuteStreaming_HoldCaptureBoundedByCallerCeiling` reported
`charged 10000 credits against a ceiling worth 32` |

Every mutation was reverted and the suite returns green.

Negative controls are in the suite too, so the clamp cannot quietly
become a discount on every request:
`TestExecuteSync_NoCeilingLeavesUsageAlone` pins that a caller who set
no ceiling is metered exactly what the provider reported,
`TestNOneOrAbsentPassesThrough` pins that only the unservable shape is
refused, and `TestSyncOverrunDispatchesOnce` pins that an over-ceiling
response does not become a second provider call.

The money tests price against the real `hive-free` catalog row
(1,000,000 credits per million input, 4,000,000 per million output,
D-048 and migration `20260824_02_free_pool_router.sql`) rather than a
fixture, so the magnitude they assert is the magnitude the issue
measured.

`TestExecuteSync_ZeroContentLength_Twice_CapturesHold` was amended
rather than deleted: its request now sets no ceiling, which is the
condition under which a capture is still at the full hold. Both halves
of that behaviour are now pinned, one per test. The three tests that
pinned the headroom inflation are removed along with the behaviour they
described.

### Verification

Run in the repo's Docker toolchain, on a throwaway toolchain container
with a worktree-unique `COMPOSE_PROJECT_NAME`. No shared database was
touched; these are `-short` unit tests.

```
go vet ./apps/edge-api/... ./apps/control-plane/...
go test ./apps/edge-api/... ./apps/control-plane/... -count=1 -short
```

Zero failures across both modules. `gofmt` is clean on every file this
PR touches. Two pre-existing `gofmt` misalignments in
`chat_completions.go` and `completions.go` were left alone as unrelated
noise.

## Review round two, first stream: five defects found in this branch,
all fixed

An independent adversarial money-path review of a2f4539 reproduced
five defects in this branch itself. Every one is answered in 8078f56,
and every reproduction is kept as a regression guard in
`completion_ceiling_review_test.go`.

**Finding 1, HIGH, caller-triggerable revenue bypass.** The ceiling was
read as the smaller of `max_tokens` and `max_completion_tokens`, but
once the headroom rewrite was deleted the outbound body forwarded both
fields verbatim, so the request boundary and the settlement boundary
enforced different numbers. OpenAI treats `max_completion_tokens` as
authoritative and `max_tokens` as deprecated, and Groq documents the
same preference, so a caller pairing `max_tokens: 1` with
`max_completion_tokens: 100000` received a full-size generation and paid
for one completion token, on the four-to-one expensive output side and
unbounded in generation size. `pinCompletionCeiling` now writes the
settled minimum back over every ceiling field present, on all three
dispatch paths, before `EnforceVariablePriceBounds` so a variable-price
alias still gets its own cap applied on top. It only ever narrows: an
absent field stays absent, and an unreadable or non-positive one is left
as written, because raising it would widen what the provider may
generate.

**Finding 2, HIGH, overcharge past the ceiling.** `clampUsageToCeiling`
is a no-op when the upstream sent no usage block, which is exactly the
branch that then prices the request from content length, so a
synchronous 200 carrying content and no usage charged far past the
ceiling. Streaming survived only by accident, because its unconfirmed
branch discards that estimate for a hold capture that is already
bounded. The bound went into `settlementCredits`, the single function
both settlement paths route through, which also closes the same hole on
the session chat surface through `ChatSettlementCredits`.

**Finding 3, MEDIUM, variable-price aliases.** On an `upstream_actual`
alias the charge is derived from the cost the upstream reported for the
generation, which the clamp never touches, so clamping the usage block
only advertised a completion count nobody was billed on: the exact
divergence that function exists to prevent. The clamp is now gated on
the pricing mode, the same test `capCaptureAtCeiling` already applies.
`hive-auto` is live in that mode.

**Finding 4, MEDIUM, published contract.** The never-billed-past-N
sentence is now qualified for the variable-price case on both endpoints
that serve `hive-auto`, rather than deleted.

**Finding 5, MEDIUM, `best_of`.** The identical unfixed defect `n` had,
on the same endpoint: nothing read or refused it, the outbound body is
re-marshalled from a map so it reached the provider intact, and the
generated OpenAPI contract advertised it. Refused on the same terms as
`n`, with the same guard shape and the same provider-blindness
assertions.

**Finding 6, LOW, documentation only.** That `max_tokens: 0`, negatives,
`8.0` and `"8"` all read as no ceiling is now stated where the invariant
is stated, with the reasoning. No behaviour change.

Finding 7 requested no change and was rebutted on the thread.

All five code findings were red before the fix and green after, and the
whole of `./apps/edge-api/...` and `./apps/control-plane/...` passes
with `go vet` clean and `gofmt` clean on every touched file.

## Review round two, second stream: three more defects, all fixed

A second independent adversarial stream ran against 8078f56, the
commit that answered the first stream. Codex is exhausted until
2026-09-10 and was replaced by Antigravity rather than skipped, on owner
instruction. It found three real defects in code this pull request
introduced, all fixed in 446151c, plus one performance observation.
Full stream output and the mutation table are in the pull request
comment.

**Finding 1, HIGH, an unparseable ceiling field survived into the
outbound body.** `pinCompletionCeiling` kept any ceiling field it could
not parse as an integer, on the reasoning that overwriting it would
widen what the provider may generate. That reasoning does not hold for a
field this gateway cannot read: it cannot be called smaller either, and
the provider may well parse it. So `max_tokens: 1` paired with
`max_completion_tokens: 100000.5` reopened the entire first-stream
bypass through a spelling the JSON number type happens not to cover. A
field is now kept only when it reads as a positive integer at or below
the ceiling, which is the rule `clampCompletionLimit` already applied to
what it cannot read.

**Finding 2, HIGH, the gateway invented a ceiling larger than the one
the caller set.** `clampCompletionLimit` fills in every ceiling field
the endpoint speaks, and filled them at
`VariablePriceMaxCompletionTokens` regardless of a smaller ceiling
already present. On chat that wrote `max_completion_tokens: 16384` in
beside a `max_tokens: 1`, and since the newer spelling is the
authoritative one, a variable-price alias generated four orders of
magnitude more than was asked for and billed the upstream cost of it,
with no settlement clamp behind it because that mode has none. The
filled value is now the lower of that constant and any smaller ceiling
the caller set, and the same limit narrows present fields too, so the
function is correct independently of call order.

**Finding 3, HIGH, hold captures priced the prompt at zero.** Both
capture branches bound the charge with `capCaptureAtCeiling`, whose
bound is the catalog price of the whole request at the ceiling, prompt
included. Both are reached precisely because no usable usage block
arrived, so the metered input count is zero, and passing that through
priced a large prompt at nothing: a 100,000-token prompt capped at ten
output tokens collapsed the capture from the hold to roughly forty
credits, a free serve of the expensive half of the request.
`captureInputTokens` now falls back to the same content-length prompt
estimate `settlementCredits` uses on the same no-usage path.
`TestExecuteStreaming_HoldCaptureBoundedByCallerCeiling` was amended
rather than deleted, because its upper bound had encoded exactly this
zero-priced prompt.

**Finding 4, LOW, three JSON decodes of the request body before
dispatch.** Partly taken: `pinCompletionCeiling` now settles the common
single-ceiling chat request with a byte scan and returns before
decoding. The rest was declined on the thread, since threading one
shared decoded map through three functions with different contracts, one
of which refuses the request outright on a decode error, is a larger and
riskier diff on a money path than a call count justifies, on a request
already about to make a network call to a language model.

Each of the three fixes was mutation-checked: reverting it turns its own
test red, and all three were red together before the fixes landed.

## Not in scope

- Issues #1282, #1285, #1286, #1288 and #1289 in the same batch are
unrelated defects with different root causes: storage writes, voice,
images, usage-shape reporting and production route ordering
respectively. None share a cause with this one, so none are folded in.
#1284 is assigned elsewhere and untouched.
- The SDK conformance assertions for these two parameters land with PR
#1268, which adds `sampling-params.test.ts`. Adding the same file here
would collide with it.
- The `WHY` prose in migration `20260826_01_route_reasoning_reserve.sql`
still describes the headroom mechanism. Applied migrations are never
edited in this repo; the column's surviving purpose is documented at its
remaining reader in `zero_content_guard.go`.

## Buglog entry

```json
{"id":"bug-2026-08-28-max-tokens-not-enforced","date":"2026-08-28","title":"max_tokens silently inflated by the reasoning reserve, billing 206x the caller's ceiling; n>1 silently truncated to one choice","error_message":"max_tokens: 8 on hive-free returned usage.completion_tokens 1650, all billed; n: 2 returned HTTP 200 with choices.length 1 and no error","root_cause":"applyReasoningHeadroom (PR #1225, issue #1171) inflated every completion-limit field the caller had set by provider_routes.reasoning_reserve_tokens (4096 on three of four hive-free members) before dispatch, so max_tokens 8 went upstream as 4104. Its premise that hidden reasoning would burn the reserve while visible content stayed inside the caller's budget is unenforceable: nothing upstream separates the two, so the model spent the inflated ceiling on visible output. Because the rewrite skipped a ceiling the caller had NOT set, its entire effect was overriding ceilings callers HAD set. Separately, ChatCompletionRequest.N and CompletionRequest.N were declared and read by nothing, so n was forwarded, ignored upstream, and one choice returned with no error. Two hold-capture branches (sync zero-content, streaming missing-usage) also charged the flat DefaultHoldText authorization floor regardless of the caller's ceiling, a far larger breach of the same invariant.","fix":"Deleted applyReasoningHeadroom and its three call sites so the caller's ceiling reaches the provider unchanged. Added completion_ceiling.go: requestedCompletionCeiling reads the ceiling from the caller's original body before any outbound rewrite, clampUsageToCeiling caps usage.CompletionTokens at it on the single usage object both the response and the charge derive from, rewriteNormalizedUsage keeps the response body in step, and capCaptureAtCeiling bounds both hold-capture branches without ever reaching zero. Refused n other than 1 with 400 unsupported_parameter and param n on both chat and legacy completions.","tags":["billing","money-path","overcharge","max_tokens","openai-conformance","issue-1283","issue-1171","edge-api","inference","free-pool"]}
```

A second entry for the three defects the review found in this branch.
Both lines go to main together in the buglog-only pull request.

```json
{"id":"bug-2026-08-28-completion-ceiling-review-defects","date":"2026-08-28","title":"Completion-ceiling fix shipped a caller-triggerable revenue bypass, an unbounded content estimate, and a usage clamp on aliases it could not bill","error_message":"Request {max_tokens: 1, max_completion_tokens: 100000} was dispatched unchanged and settled at output_tokens 1, actual_credits 24 where the unclamped generation was worth 6620; a synchronous 200 carrying content and no usage block against max_tokens 8 charged 1323 credits versus 32 ceiling-priced; on an upstream_actual alias the caller read completion_tokens 8 while paying the upstream cost of 1650","root_cause":"Three separate causes in one change. First, requestedCompletionCeiling takes the smaller of the two chat spellings but nothing rewrote the outbound body, so after applyReasoningHeadroom was deleted both fields were forwarded verbatim and the request boundary enforced a different number than settlement; OpenAI and Groq both prefer max_completion_tokens, so the larger field governed generation while the smaller governed billing. Second, clampUsageToCeiling returns early when usage is nil, which is the same condition that routes settlement into the content-length estimate in settlementCredits, leaving the one path that guesses the completion count as the one path with no ceiling on it; the streaming path was masked because settleStream overwrites the estimate with a bounded hold capture. Third, clampUsageToCeiling was called unconditionally, but an upstream_actual alias settles from respBody or acc.RawUsageChunk, which the clamp never reads, so it lowered the number the caller reads without lowering the charge.","fix":"Added pinCompletionCeiling, which writes the settled minimum back over every ceiling field present in the outbound body before dispatch and only ever narrows, wired on executeSync, executeStreaming and executeResponsesStreaming ahead of EnforceVariablePriceBounds. Added a ceiling parameter to settlementCredits that bounds the content-length completion estimate, with ChatSettlementCredits reading the ceiling from the request bytes it already parses so the session chat surface is covered too. Gated clampUsageToCeiling on route.Pricing.IsUpstreamActual, the same test capCaptureAtCeiling applies. Refused best_of other than 1 on legacy completions. Qualified the support-matrix sentence for the variable-price case and documented that non-positive and non-integer ceiling spellings read as no ceiling.","tags":["billing","money-path","revenue-bypass","overcharge","max_tokens","max_completion_tokens","best_of","upstream-actual","issue-1283","pr-1305","edge-api","inference","code-review"]}
```

A third entry for the three defects the second adversarial stream found.
All three lines go to main together in the buglog-only pull request.

```json
{"id":"bug-2026-08-28-completion-ceiling-second-stream-defects","date":"2026-08-28","title":"Ceiling pinning kept unreadable fields, the variable-price bound invented a larger ceiling than the caller set, and hold captures priced the prompt at zero","error_message":"Request {max_tokens: 1, max_completion_tokens: 100000.5} was dispatched with the float intact; a variable-price request sending only {max_tokens: 1} was dispatched with max_completion_tokens 16384 written in beside it; a streaming request with a prompt of roughly 11334 tokens and max_tokens 8 whose upstream sent no usage block settled at 32 credits rather than the price of the prompt","root_cause":"Three causes in the fix for the first adversarial stream. First, pinCompletionCeiling skipped any present ceiling field that failed to unmarshal into int64, on a never-widen rule that does not hold for a value the gateway cannot read at all, since the provider may parse what encoding/json refuses and OpenAI treats max_completion_tokens as authoritative. Second, clampCompletionLimit sets every field the endpoint speaks to VariablePriceMaxCompletionTokens when absent, with no reference to a smaller ceiling already present, and an upstream_actual alias has no settlement clamp behind that. Third, capCaptureAtCeiling computes its bound as the catalog price of the whole request at the ceiling, but both callers pass the metered input count, which is zero exactly because those branches are reached when no usable usage block arrived, so the prompt was priced at nothing and the capture collapsed to the output side alone.","fix":"pinCompletionCeiling now keeps a field only when it reads as a positive integer at or below the ceiling and overwrites everything else present, logging the raw literal truncated. clampCompletionLimit derives an effective limit as the lower of VariablePriceMaxCompletionTokens and any smaller positive ceiling already in the body, and uses it both to fill absent fields and to narrow present ones. Added captureInputTokens, which falls back to the content-length prompt estimate settlementCredits already uses when the upstream reported no usage, and wired it into both capCaptureAtCeiling call sites. Added a byte-scan early-out to pinCompletionCeiling so an ordinary single-ceiling chat request pays no extra decode.","tags":["billing","money-path","revenue-bypass","free-serve","max_tokens","max_completion_tokens","upstream-actual","hold-capture","issue-1283","pr-1305","edge-api","inference","code-review"]}
```
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
…ing pool

The intermittent failure on the multi-turn tool round trip is a real gateway
defect, not a flaky test, and not the pool-member class the top_k defect
belonged to. Diagnosed against the live gateway on the demo box rather than
inferred.

What happens. The second turn of a tool round trip, after the tool result goes
back, asks a reasoning model to write the final answer. deepseek-v4-flash
spends most of its ceiling on hidden reasoning: measured over ten attempts at
the suite's own ceiling of 256, reasoning_tokens ran 22 to 148 out of 49 to 193
total completion tokens. When reasoning overruns the ceiling entirely, the
upstream answers content null with finish_reason length. Forcing that case by
lowering the ceiling to 120 reproduced it on two of eight attempts, with
reasoning_tokens of 117 and 124.

So, taking the three readings in the brief in order. The empty content is a
legitimate provider response in the narrow sense that the provider really did
answer that way, but it is not a usable one: finish_reason is length, the model
did not finish, and a caller asking for the final turn of a tool round trip is
entitled to text. The assertion is right to demand it. No provider in a pool
handles the tool-result turn differently; this is one route, and it is the
pinned single-route alias, not hive-free. And the gateway is NOT dropping
content the upstream returned: the upstream sends null, and coerceNullContent
turns it into an empty string precisely so an SDK that dereferences content
unconditionally does not crash.

The actual defect is the gate on the existing zero-content guard. That guard
already retries an empty length completion once, which is exactly the right
answer here, but it only ran when route.ReasoningReserveTokens was positive.
That column does not mean "this route reasons": it means "somebody enumerated
this route in migration 20260826_01", which covered three free-pool members and
nothing else. deepseek-v4-flash carries the column default of zero while
reasoning heavily, so the guard sat out the one path where it was needed most.

The gate is now the response shape itself, which needs no catalogue column to
interpret: an empty length completion is worth one retry whatever route
produced it. The test that asserted the opposite is inverted, with the
measurement in its comment.

Also corrects the max_tokens contract in the conformance suite. It asserted a
reserve-inflated ceiling on the pooled alias, citing a mechanism that PR #1225
added and issue #1283 removed. The current code sends the caller's ceiling
upstream untouched, which the live probe confirms: a ceiling of 120 came back
as exactly 120 completion tokens. Both aliases now assert the ceiling the
caller asked for.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

free pool: decide max_tokens semantics across reasoning and non-reasoning members

1 participant