OpenAI API Instrumentation (e.g. seed, logprobs, echo, an array prompt) - #1353
Open
monotophic wants to merge 11 commits into
Open
monotophic wants to merge 11 commits into
monotophic wants to merge 11 commits into
Conversation
Upstream dev rejected any non-null `seed` with HTTP 400. Per-request seeding has no wire path to any engine's RNG, so accept and discard it instead of refusing the request, and document the no-op honestly: glm and inkling seed their own process-global RNG from `SEED` at launch, never per request, and no other engine reads `SEED` or a per-request seed at all. Proven with a test asserting the seed value never reaches the SUBMIT wire frame. Adjacent consequence: renamed the golden-fixture capture case "err_seed" to "seed_accepted" in c/tests/golden_fixture_capture.py (it now captures a 200, not a 400), and updated that file's docstring to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…the prompt On /v1/messages with stream: true, the HTTP 200 and SSE preamble were committed before the engine validated the prompt, so a pre-accept refusal (e.g. context-length exceeded) surfaced as a committed 200 followed by a truncated stream. Defer the commit into a start_stream() closure fired on the engine's on_accept, matching the OpenAI streaming path: a pre-accept refusal is now a clean HTTP 400 in the Anthropic error envelope, and a healthy engine's stream is byte-for-byte the same as before. This adds a new invariant to the Engine.generate() contract: any implementation that never calls on_accept now loses the entire Anthropic SSE body, since nothing downstream fires until that callback does. Production cannot hit it -- on_accept is always called no later than the first decoded token -- but an engine binary predating the ACCEPT frame (JustVugg#597) only calls it on the first DATA/DONE, so a cold, multi-minute prefill against such a binary now sends zero bytes over the wire during that wait, where the previous behavior sent message_start plus a keepalive ping every 10 seconds. A client with a short idle timeout could newly time out in that specific pairing. Also guard the commit itself against a client that vanishes at the exact moment the engine accepts: a write failure there used to unwind out of the generation loop with nothing sent, leaving the request stuck in the engine's pending map with no CANCEL ever going out. And give the streaming test double in test_anthropic_messages.py's FakeEngine an on_accept call, matching the one test_openai_server.py's FakeEngine already has, since the deferred commit needs it to exercise the stream at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adds logprobs_options(), a pure helper that normalizes the completions integer-logprobs/echo shape and the chat boolean logprobs/top_logprobs shape into one (engine_k, echo, display_k) tuple, enforces the LOGPROBS_TOP_K_CAP top-k range, and refuses an unsupported engine with a named 400 instead of a silent no-op. A non-boolean `echo` is a named 400 on both endpoints regardless of whether `logprobs` was requested, and chat's `top_logprobs` is type- and range-checked even when `logprobs` is false or absent, so a malformed value is never silently let through by a sibling field that made the check seem moot. Adds six pure response-assembly helpers that turn the engine's numeric logprobs records into the two OpenAI response shapes: the legacy completions object (tokens/token_logprobs/top_logprobs/text_offset) and the chat logprobs.content array. They place echoed prompt positions by the wire's own position field rather than arrival order, reconstruct text offsets correctly across multibyte characters split between tokens, serialize non-finite logprobs as JSON null, and label the chosen token by value rather than by top-k table rank -- deterministically, via a documented first-exact-match tie-break, when the engine's 6-decimal printed value collides between two distinct candidates (the wire record carries no chosen-token id to disambiguate by identity). Wires it all into the request path: the SUBMIT writer opts in with logprobs=k only when requested and supported, generate() parses the engine's numeric tail on DATA/ECHO frames and collects the resulting per-token records into prompt/generated lists (bounded by COLI_LOGPROBS_ACCEPT_TIMEOUT so a pre-JustVugg#597 engine that silently rejects the extended SUBMIT header cannot wedge the caller forever), and the completions and chat handlers assemble the OpenAI-shaped logprobs objects from them. Streaming together with logprobs is refused with a named 400, as is any logprobs request against an engine that does not implement the numeric channel. Completions echo now reconstructs the prompt into `text` itself, not only into the logprobs object; a filtered stop token's own record is dropped so the logprobs arrays never describe a token the client did not receive; prompt-echo records are retained only when the request actually asked to see them -- the engine sends every ECHO frame regardless, so an opted-in request that never asked to see the echo table would otherwise hold a full prompt-length record for nothing. Adds the corresponding docs section and the disclosed golden-fixture- capture rename for the case whose status code changed ("err_logprobs" to "logprobs_served"). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
/v1/completions' `prompt` now also accepts a JSON array: either a flat
list of integer token ids (a pre-tokenized prompt) or a list of
strings/token-id-lists (a batch, dispatched as independent generations
sharing one HTTP response). Each array is validated structurally
regardless of size: it must not be empty, and its element types must
be homogeneous -- all strings, all token ids, or all token-id arrays,
never mixed -- each a named 400 on `prompt`. A single-member array
produces exactly what the same prompt would have produced as a
single-prompt request, including a nested batch-of-one token-id array,
which unwraps to the identical flat behavior. A flat token-id array is
glm-only; every other engine refuses it with `unsupported_parameter`
rather than silently reinterpreting the ids as text.
A real batch (more than one member) dispatches one choice per member,
`choices[i].index == i`, in the same order the array was given, over
one HTTP response (`stream: true` is refused outright, `param:
"stream"`; a batch's `n` other than 1 is refused the same way the flat
path refuses it, `param: "n"`). Validation runs in one fixed order,
matching the flat path for `cache_slot` and the array's own shape and
homogeneity checks, with one exception: `generation_options`
(`max_tokens`, `n`, `temperature`, `top_p`, `stop`) is validated once
per member, after the array shape check but before any member is
submitted to the engine -- so a malformed member fails the whole batch
before any other member's generation has started, and its `param` is
rewritten to `prompt[i]` (except `stream`/`cache_slot`, which are
request-level). A batch's assembled response is held in memory in full
until its single write, so its generated-token total (every member's
effective `max_tokens`, after the server's own clamp to its configured
`--max-tokens`/`--ngen`, summed across members) must not exceed
`PROMPT_BATCH_COMPLETION_BUDGET` (65,536), checked after per-member
validation so a malformed member is still reported by its own defect
first. With `echo` and `logprobs` the same hold additionally retains
every member's echoed prompt positions, bounded instead by
`PROMPT_BATCH_TOKEN_BUDGET` (65,536) times the per-position top-k
table (`LOGPROBS_TOP_K_CAP`, 32) -- roughly double the generated-side
figure in the worst case. Both budgets, and the batch member-count cap
(`PROMPT_BATCH_CAP`), apply to real batches only; a length-1 array is
exempt and keeps the flat single-prompt path's own oversize handling.
Each member is submitted through the engine sequentially, each with
its own isolated decoder state, so one member's malformed UTF-8 tail
or tool-call parsing never contaminates another's. A client-fault
error from any one member (context-length exceeded, an unsupported
token id, and so on) is reported with `param` rewritten to `prompt[i]`
and keeps the engine's own `code`; a server-fault error keeps its own
status/code/param and only gains the member index in its message. A
client that disconnects mid-batch is checked before each member's
submit and stops the loop there instead of running the remaining
members for a dead socket. The scheduler admission is held only for
the submit-and-collect phase and released before the response is
serialized and written, not across it.
Adds the corresponding docs/api.md section and the disclosed golden-
fixture-capture rename for the case whose status code changed on glm
("err_array_prompt" to "array_prompt_token_ids"; every other engine
keeps its 400, now with the named `unsupported_parameter` code).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
group_score is not wired up in this build, and its eventual contract changes the response shape (continuation-only logprob arrays), so silently ignoring the opt-in would hand a client a differently shaped answer than it asked for. The completions-only guard now refuses a truthy `group_score` with a named 400 before either the flat or the array/batch request shape is intaken. Truthiness is checked by identity against `None`/`False`, not equality -- `0 in (None, False)` is true in Python, so an equality check would let `group_score: 0` or `0.0` slip past the guard unrefused. Chat and Anthropic requests never look at the field at all; the guard is completions-only by design. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Teach the stdout dispatcher to read past four group-scoring frame kinds (GRPP, GRPG, GRPS, GRPE) a future engine may interleave with ordinary request frames on the same pipe, so this server stays correct if ever paired with such an engine. No engine in this tree emits them yet; they serve a group-scoring wire channel proposed separately. Payload-carrying kinds (GRPP, GRPG) are drained with the same size bound and terminator check as DATA, naming their own kind on a truncated payload; header-only kinds (GRPS, GRPE) are consumed with no further effect. None ever reach a request's event queue. The ECHO frame arm gets the same kind-naming treatment: its payload read now reports "truncated engine ECHO payload" on a short read, matching the size and terminator checks ECHO's own arm already uses, instead of falling back to the shared "DATA" wording. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A write onto a dead engine's stdin raised BrokenPipeError, a
ConnectionError subclass that do_POST's client-hangup handler silently
swallows -- the client saw a closed connection instead of a 500.
SUBMIT's IMAGE-plus-header write now wraps its OSError into a named
RuntimeError, and the CANCEL/STOP writes now share one checked
_write_frame helper with the same wrap; both surface as HTTP 500
engine_error, as long as the response has not already been committed
(a failed write on an already-committed stream ends the stream instead
of producing a 500). Wire bytes for every frame kind are unchanged.
The engine's stdin is a raw, unbuffered pipe, so a single write() call
can take fewer bytes than it was given; SUBMIT and _write_frame now
loop, re-offering the remainder until every byte is consumed, and a
write that makes no progress (`None` or 0 returned) fails closed as
the same named engine-write error instead of spinning forever. A
two-thread test pins that _write_frame's lock keeps concurrent frames
from interleaving on the wire.
Documents the resulting protocol contract in docs/api.md: server-to-
engine writes are checked and engine-to-server frames are strictly
validated (a malformed frame fails every in-flight request with a 500
and stops the dispatcher rather than desynchronizing the stream, and
that list now includes the four group-scoring frame kinds this program
also added), and the engine child still runs under the default SIGPIPE
disposition. Also corrects the SIGPIPE-disposition precondition test:
it captured `kwargs.get("restore_signals", True)` and asserted it
truthy, which cannot distinguish "no kwarg passed" from an explicit
`restore_signals=True` -- production passes no such kwarg at all.
Assert the kwarg is genuinely absent instead, and pin Python's own
default (`True`) separately via `inspect.signature` so a future stdlib
change to that default would still be caught here.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
self.pending[request_id] was only cleared on three paths: a SUBMIT write failure, the logprobs/token-id accept-timeout, and the dispatcher's own DONE/ERROR frame handling. Every other way generate() can exit -- most concretely, a broken engine stdin surfacing through the CANCEL/STOP write a disconnected or stopped client triggers -- left the request's queue behind in self.pending indefinitely. The leak is bounded (a later dispatcher failure clears the whole map via _fail_pending), but nothing before that point ever reclaims the slot. Wrap the request's lifetime, from admission through every raise or return, in a try/finally that pops self.pending[request_id] unconditionally. The SUBMIT-write failure branch and the accept- deadline branch each had their own explicit pop for this same reason; both are now redundant and are folded into the single finally. This gap predates this branch: the same pending map, the same generate() shape, and the same unwrapped stdin.write() calls for CANCEL/STOP are already present on dev, so the fix applies there too, not only to the logprobs/echo path added here. Added a regression test that forces the leak's most direct trigger -- a disconnected client's CANCEL write failing against a broken engine pipe -- and asserts self.pending is empty afterward; it fails against the old code and passes with the finally in place. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two of docs/api.md's array-prompt contract sentences had no falsifying test: a token-id batch's aggregate prompt-token budget counts actual tokens (only string batches, counted in UTF-8 bytes, were ever exercised), and a batch that omits cache_slot is admitted once, with every member sharing the single slot the scheduler picked. Both tests were verified to fail against a reverted version of the corresponding server behavior before landing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ed Anthropic stream A failed CANCEL/STOP write reaching an already-committed stream was only pinned at the Engine.generate() level; nothing proved what a client actually sees on the wire once that failure reaches do_POST's committed branch. Drive a real Engine + fake subprocess whose STOP write fails through a live streaming request and read the raw socket: the earlier SSE bytes survive, no second status line or error body is spliced in, the stream just ends, and the failure is logged. The Anthropic /v1/messages streaming path defers its HTTP 200 until the engine accepts, but nothing exercised the window itself: what a client sees while the engine is still "prefilling". Hold generate() open with BlockingEngine and read the socket mid-wait to confirm zero bytes arrive, then release it and confirm the deferred 200 and full SSE sequence still land once the engine finally accepts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The bound-blob check hashed the fixture's working-tree bytes. On a checkout that converts line endings (git's autocrlf on the Windows CI runner) the same committed blob arrives with CRLF and the digest no longer matches, which is what the Windows UCRT64 job reported. Normalise CRLF to LF before hashing and measuring; the pinned digest and length are those of the committed LF bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Edo771977
added a commit
to Edo771977/colibri
that referenced
this pull request
Sep 14, 2026
Integrazione PR upstream: CUDA Qwen3.8 (JustVugg#1424), QLoRA (JustVugg#626), API logprobs/echo (JustVugg#1353)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Authored by Claude Opus in Claude Code, analysis in partnership with @monotophic
The OpenAI-compatible server refuses several ordinary request fields outright
today, and the pipe it speaks to the engine over is checked in neither
direction. This changes both. It is six files — Python, one JSON test fixture,
and documentation. No engine source, no build change.
seedis accepted and ignored rather than rejected. No engine and no fieldon the wire protocol reads a per-request seed, so the value genuinely has no
effect at any temperature;
docs/api.mdsays exactly that instead of implying adeterminism this build cannot deliver.
Per-token log probabilities and prompt echo are served on the glm engine.
The engine on
devalready implements the numeric channel —c/decode_batch.hparses the
logprobs=kandids=1SUBMIT extensions andc/colibri.calreadyemits the per-token tail on
DATA/ECHOframes. Only the server side wasmissing.
/v1/completionsnow takes the legacy integerlogprobs(0–32) andboolean
echo;/v1/chat/completionstakes booleanlogprobswithtop_logprobs. Every other engine refuses the fields with a named 400 ratherthan ignoring them, and an engine build older than the SUBMIT extension is
answered with a named 503 after a bounded wait instead of leaving the request to
hang. The costs are documented rather than hidden: opting in forfeits
prefix-cache reuse for that request, the top-k table is unsorted on the wire,
and the sampled token is not guaranteed to appear in its own table.
promptaccepts arrays. A flat array of token ids is one pre-tokenizedprompt; an array of strings or an array of token-id arrays is a batch — the
shape unmodified
lm-evalsends for tokenized loglikelihood requests, whichdevrejects. A batch is all-or-nothing: every member's shape is validatedbefore the first engine submit, members are submitted one at a time each with
its own UTF-8 decoder and its own echo reassembly, any member's failure fails
the whole request with that member named as
prompt[i], and nothing reaches theclient until every member has finished. A member cap and two token budgets bound
what one request can hold in memory.
group_score— a future opt-in that wouldchange the response shape — is refused with a named 400 rather than silently
ignored, because ignoring it would answer a different question than the client
asked.
The server↔engine protocol is fail-closed in both directions. Every
SUBMIT/CANCEL/STOPwrite is checked, so a write onto a dead engine becomesa named HTTP 500 instead of a silent connection close; upstream's frame-writer
structure and its
IMAGE-frame ordering are untouched — the checking isexpressed inside them. In the other direction the dispatcher validates frame
grammar rather than desynchronizing, and it drains four group-scoring frame
kinds (
GRPP/GRPG/GRPS/GRPE) so that a server built from this branch stayscorrect if it is ever paired with an engine that emits them. No engine in this
tree emits them (verified by grep over the whole repository at this head: the
only hits are this server, its tests, and
docs/api.md); the engine that wouldis a group-scoring channel proposed separately, and nothing in this PR waits for
it.
One default-path behaviour is changed deliberately:
/v1/messagesstreaming nowcommits its HTTP 200 on the engine's
ACCEPTrather than at scheduleradmission, matching what the OpenAI-style endpoints already did. A refusal
discovered before acceptance is now a real HTTP 400 in the Anthropic error
envelope instead of a committed 200 followed by a truncated event stream. On a
healthy engine the framing and event order are unchanged; the consequence to
know is that a request queued behind another generation now waits silently,
exactly as the OpenAI-style path already made it wait.
Context. This PR is one of a set of independent contributions derived from a
single locally-verified working tree: fp8 container support (already proposed as
#1102), this server API work, four evidence-tooling and engine-instrumentation
contributions, and a group-scoring serve channel proposed later. This one
carries the OpenAI-compatible server's request surfaces and its engine-protocol
contract. It stands alone — measured, not assumed: its branch sits directly on
devate1efc68(its merge base withdevisdevitself), its whole suiteis green there with nothing else from the set present (
make check, 908 tests),and merging or declining the others does not affect it; separately, the three
programs finished so far were merged together locally and the combined suite is
green (1244 tests), so the set is also known to compose. The others are proposed
separately, each with its own evidence.
Behavioral contract
Everything that worked before still works.
SUBMITheader and a byte-identical response body to
dev's.SUBMIT,SUBMITpreceded byIMAGE,CANCEL,STOP— is byte-identical todev's, in the same order,under the same single lock acquisition.
devstill exists here, in both server testmodules; none was removed to make room for a new behaviour.
/v1/messagesstreaming commit point,described above.
seedseedon/v1/completionsor/v1/chat/completionsisprocessed exactly as the same request without it.
Log probabilities and prompt echo
/v1/completionsaccepts integerlogprobs0–32 and booleanecho;0,falseandnullall mean "no log probabilities" and returnchoices[].logprobs: nullwith a 200, while booleantrueis a named 400./v1/chat/completionsaccepts booleanlogprobsplus integertop_logprobs(0–32) and returns
choices[].logprobs.content[]; any integerlogprobsis anamed 400, and
echois refused.top_logprobsis type- and range-checked on chat even whenlogprobsisfalse or absent; a non-boolean
echois a named 400 on both endpoints.named 400 — never a silent no-op.
logprobstogether withstreamis a named 400.SUBMITproduces a named503 within
COLI_LOGPROBS_ACCEPT_TIMEOUT(default 30 s), never a hang; therequest is treated as cancelled rather than left pending.
null, never a clamped number. Echopositions are placed by the wire's own
posfield, not arrival order.text_offsetis a character offset into the returnedtext, counted from 0.The chosen token's log probability is read from its own record, not looked up
by rank in the top-k table.
Array and token-id prompts on
/v1/completionsbatch-of-one — produces exactly what the same prompt produces as a flat
single-prompt request.
token-id arrays), at any size; each violation is a named 400 on
prompt.returns a named 400 rather than mis-tokenizing decimal digits as text.
choices[i].index == i, in order,with
usagesummed across members.malformed member N is a clean 400 naming
prompt[N-1]with zero submits made.still attributed to that member; a client-fault error keeps the engine's own
status, code and
paramwith only the message gaining the index; an enginefailure the server cannot name is one un-attributed 500 for the whole batch.
disconnects mid-batch stops it at the member in flight.
never leak into another member's text.
members counted in tokens, string members in UTF-8 bytes as an upper bound);
and, on the generated side, at members × the request's effective
max_tokensnot exceeding 65,536, checked before any engine submit. A length-1 array is
exempt from both budgets and keeps the flat path's own oversize handling. The
scheduler admission is released before the response is written.
stream: trueandnother than 1 are refused with a named 400 on a batch.group_scorepresent and neithernullnorfalseis a named 400(
code: "unsupported_value") on/v1/completions, flat or array, before anyprompt intake or engine submit.
/v1/chat/completionsand/v1/messagesdonot read the field.
Engine protocol
engine_errorwhile the response is uncommitted, and ends the stream once itis committed — never a silent close.
GRPP/GRPGpayloads are drained under the same size bound and terminatorcheck as
DATA, andGRPS/GRPEare consumed with no effect; nothing fromthem reaches any request's event queue. A malformed group frame raises the
dispatcher's existing named error.
devis handled identically.disconnected reader terminates it at its next write with no evidence records
after the failure point.
Capstone matrix
One decisive artifact per claim; each would fail if the claim were false. Test
citations are file:line at this branch's head.
seedreaches no wire framec/tests/test_openai_server.py:2209test_seed_accepted_and_absent_from_submit_frame— theSUBMITframe bytes for a seeded request are compared against the unseeded request's; any leak of the value changes themc/tests/test_openai_server.py:4305asserts the exactSUBMITheader literal, and:4418asserts the plain completions response's field set and valuesc/tests/test_openai_server.py:6198test_wire_transcript_is_byte_identical_to_base— the wholeSUBMIT/IMAGE+SUBMIT/CANCEL/STOPsequence is compared to a literal captured transcript, so a reorder, a missing flush boundary or one stray byte fails itc/tests/test_openai_server.py:4251test_break_it_logprobs_rejected_for_non_glm_enginec/tests/test_openai_server.py:4703test_old_engine_rejection_times_out_with_a_named_503— it runsgenerate()on its own thread precisely so that a missing deadline fails this one test instead of hanging the suitec/tests/test_openai_server.py:4831and:4834import the module in a fresh subprocess with the real variable name set and unset, so a typo in the name or a changed default cannot ship greenc/tests/test_openai_server.py:4093test_nested_batch_of_one_prompt_is_identical_to_flatc/tests/test_openai_server.py:5224test_rejected_batch_makes_no_engine_submits— the engine's call count is unchanged across three differently-malformed requestsc/tests/test_openai_server.py:5256test_context_exceeded_mid_batch_names_member_in_param—param: "prompt[1]"with the engine's owncode: "context_length_exceeded"preservedc/tests/test_openai_server.py:5502and:5486compare every batched choice, field by field, against the same prompt sent alone — strings and token ids--kv-slots 1: the same three prompts were sent as singles, as a batch, as a reversed batch, as singles again, and as a duplicate-member batch. Every(text, finish_reason)was identical across all five arms, and the batch's aggregatecompletion_tokensequalled the sum of the singles'. The prefix cache was live and demonstrably reached — the first request loggedprefix 0/68 token, prefill 68and later ones loggedprefix 68/68 … prefill 0andprefix 40/68 … prefill 28— so identity holds with another member's residue in the slot, not because the slot was never warm. All 20 requests landed on slot 0, and each batch was one engine admission. Corroborating the "no engine change" claim: the engine built from this branch's own tree is sha256-identical to the engine built fromdev.group_scorefails closedc/tests/test_openai_server.py:5157test_group_score_opt_in_is_refused_fail_closed, on both the flat and the array request shapec/tests/test_openai_server.py:2007test_group_frames_between_data_frames_drain_leaving_second_data_intact— each kind is interleaved between a request's own twoDATAframes and the second must still arrive byte-for-bytec/tests/test_openai_server.py:4199test_array_prompt_token_budget_counts_actual_tokens_for_token_id_batches— an over-budget token-id batch gets the documented refusal and the engine sees no submitcache_slotruns every member on the one slot the scheduler pickedc/tests/test_openai_server.py:5804test_batch_without_cache_slot_shares_the_one_slot_the_scheduler_picked— every recordedgeneratecall carries the same slotCANCEL/STOPwrite reaching an already-committed stream ends the stream, never splices a 500c/tests/test_openai_server.py:3822KeepAliveFramingTest.test_write_failure_reaching_the_committed_stream_ends_it_cleanly— a realEngine+ fake subprocess ACCEPTs, streams partial text, then fails theSTOPwrite; exactly one HTTP status line reaches the client, the pre-failure text survives, no[DONE]and no error body are spliced in, the connection closes, and the failure is logged/v1/messagesstreaming sends no bytes at all until the engine's first accept-equivalent frame, then delivers the deferred 200 and full SSE sequence unchangedc/tests/test_openai_server.py:3617AnthropicColdPrefillTest.test_no_bytes_reach_the_client_until_the_engine_finally_accepts— with the engine held open beforeon_accept, the raw socket yields a bounded read timeout (zero bytes); once released,HTTP/1.1 200plusmessage_start…message_stoparrive intactc/tests/test_openai_server.py:5877test_dead_engine_submit_is_a_named_500_engine_error_not_silence— at the pre-fix revision theBrokenPipeErrorfell into the client-hangup handler and the client saw a silent closec/tests/test_openai_server.py:6249— a real fork/pipe child underSIG_DFLis killed by signal 13 at its next write with no completion record after the failure point, and:6267pins the precondition by asserting the server passes norestore_signalskwarg at allc/tests/test_openai_server.py:3464gets a real 400 in the Anthropic envelope with zero SSE bytes wheredevsent a 200;:3521pins the healthy-engine framing and exact event order as unchangedFuller matrix, disclosures, known gaps, origin accounting, and namespace claims
Requirement → instrument → result
make check(clean + portable C build + C suite + Python suite)Ran 908 tests … OK (skipped=42), 4m39stest_openai_server.pyrun three more timesLOGPROBS_TOP_K_CAP == 32at:573,PROMPT_BATCH_CAP == 128at:4127, both 65,536 budgets at:4161and:5596%.6fform the engine ondevprints, with non-dyadic values so a different grammar is distinguishablenan/inf/-infparse and serialize as JSONnull:702,:806):5560), and hostile echo positions mid-batch (:5572):5233mid-batch failure,:5346client disconnect mid-batchdocs/api.mdsays "signal 13 (wait status 141)"; the test asserts the child's return code is-signal.SIGPIPEmake checkexit 0, 1244 tests OK (45 skipped) — verified together locally, on top ofdevate1efc68Disclosures
GRPP/GRPG/GRPS/GRPEdrain arms have no emitter in this tree.A repository-wide grep at this head finds them only in
c/openai_server.py,its test module, and
docs/api.md— no engine source, no header. They aredrained, never surfaced: nothing from them reaches any request. The engine
that emits them is a group-scoring serve channel proposed separately; if you
would rather not carry protocol tolerance ahead of its producer, the commit
that adds them (
feat(api): drain group-scoring frames a future engine may emit) is self-contained and can be dropped from this PR without touchinganything else.
/v1/messagesstreaming first-byte timing. This is the one default-pathbehaviour change, and it is intended. With the in-tree engine the delay is one
SUBMIT→ACCEPTround trip, because the engine emitsACCEPTbefore prefilland polls its stdin on every decode step. Against an engine binary old enough
never to send
ACCEPTat all, the first_acceptfires on the firstDATAor
DONEinstead, so a cold multi-minute prefill would send zero bytes wheredevsentmessage_startplus a periodicping— a client with a short idletimeout could newly time out. That mismatched pairing is not covered by a
test; see the gaps below. The change also creates a genuine new expectation
on the
Enginecontract: agenerateimplementation that never callson_acceptnow yields no Anthropic SSE body at all. Production cannot reachthat (
_acceptis guaranteed before the firstdecode), but any in-tree testdouble must model the
ACCEPTframe, and the two-line change toc/tests/test_anthropic_messages.pyin this PR is exactly that.environment variable,
COLI_LOGPROBS_ACCEPT_TIMEOUT(default 30 seconds),which bounds how long an opted-in request waits for the engine's
acknowledgement before being cancelled and answered with a named 503. It is
documented in
docs/api.mdand pinned by its real name and its default in thesuite rather than only through the module attribute the rest of the tests
patch. The other public knobs this PR documents — the 32-entry top-k cap, the
128-member batch cap, and the two 65,536 budgets — are module constants, not
environment variables, and each is pinned to its literal value by a test.
test_openai_server.pygoesfrom 161 to 336 test methods and is now roughly 96 seconds of the Python
suite's ~140. That is the dominant cost of this change; the C suite and the
build are untouched.
Known gaps, stated plainly
None remain open. The four items an earlier draft listed here — token-id batch budgeting,
cache_sloton a batch, awrite failure reaching an already-committed stream, and Anthropic streaming against an engine that never accepts — are
each pinned by a test named in the capstone matrix above (the last two added at the head of this branch).
Origin accounting
The base is unmoved: this branch's merge base with
devisdevitself, sono change here is caused by upstream motion. By commit:
feat(api): accept and discard per-request seed (documented no-op)err_seedcase is renamedseed_accepted, because the case now captures a 200.fix(api): defer the Anthropic streaming 200 until the engine accepts the promptEngineexpectation described above and the two-lineACCEPTsimulation the in-tree Anthropic test double needed.feat(api): serve OpenAI-style logprobs and prompt echo on the glm enginetop_logprobsis validated on chat even whenlogprobsis off (it was silently ignored),echo's boolean check is the same on both endpoints, and the top-k cap is pinned by its literal value rather than by the symbol it polices. Adjacent consequence: the capture battery'serr_logprobscase becomeslogprobs_served.feat(api): accept array and token-id prompts on /v1/completionscodevalues forparam: "prompt[i]"was presented as closed when the engine's own mid-batchcontext_length_exceededis preserved through the member rewrite (the branch's own passing test asserts it), and the multi-prompt section claimedstream: truewas refused "the same as everywhere else on this endpoint" when the flat path refuses only the narrowerlogprobs-plus-streamcombination and names a differentparam. Both now say what the code does, including theparamdivergence a client keying offerror.paramwould hit. Adjacent consequence: the capture battery'serr_array_promptcase becomesarray_prompt_token_ids, because this PR moves that case from a 400 to a 200 on the glm engine — the third of the three renames.fix(api): fail closed on an unsupported group_score opt-infeat(api): drain group-scoring frames a future engine may emitfix(api): check every server->engine frame writeSUBMITblock keeps its shape and itsIMAGE-frame ordering under one lock acquisition and gains only a not-running check, a flush, and anOSError→named-RuntimeErrorconversion. The fiveCANCEL/STOPsites, which were five identical inline blocks, call one small helper; that helper is deliberately not used bySUBMIT.fix(api): drop Engine.generate()'s pending-map entry on every exitdev, found by re-review of this branch, not one this branch introduces.dev'sgenerate()has the same pending map with nofinally: the entry is popped only on aSUBMIT-write failure and by the dispatcher's ownDONE/ERRORhandling, so a write failure on theCANCEL/STOPpath leaves it behind. Bounded ondev— that error means the engine's stdin is broken, so the dispatcher hits EOF and clears the map — but it should not depend on that. Note for review: this commit's diff looks large and is almost entirely re-indentation; ignoring whitespace it is 11 changed lines inc/openai_server.pyplus one test.Style note: changed lines were held to the file's measured local idiom via a
diff-scoped consistency check; no surrounding code was reformatted.
Shared-namespace claims
This PR claims the following names in namespaces other contributions could
collide with. The list is scanned against
devHEAD and against every open pullrequest's diff immediately before push, and re-scanned if the push slips.
COLI_LOGPROBS_ACCEPT_TIMEOUT(one; new).codestrings:prompt_batch_cap_exceeded,prompt_batch_token_budget_exceeded,batch_completion_budget_exceeded,engine_tok_ids_unsupported(four; new). Existing codes reused unchanged:invalid_value,unsupported_value,unsupported_parameter,context_length_exceeded,engine_error.SUBMITextension keys thisserver now sends,
logprobs=kandids=1, are already defined and parsed bythe engine on
dev(c/decode_batch.h); this PR adds a sender, not a key.The four
GRP*frame kinds are consumed only — nothing here emits them.Durable vs current state: the request contracts, the error codes, the
protocol rules and the documented limits are durable. The counts (908 tests at
this head, 336 in the server module, 1244 for the three programs merged
together) and the fleet run quoted above are current state, measured 2026-09-05
against base
e1efc68on macOS (Apple silicon) and, for the KV-slot cell, on aLinux CPU host with GCC.