Repository navigation
feat: stop delivery on client disconnect across providers, and opt-in non-streaming timing for all providers - #579
Merged
Conversation
Add the nonStreamingTimingAllProviders field to Fixture and FixtureFileEntry, pass it through the fixture loader, and warn when either non-streaming timing opt-in is not a boolean.
commit: |
Add golden byte and delay snapshots for every streaming codec and handler, the exported-writer base-contract matrix recorded at 508dd9b, and the shared delivery-lifecycle harness that the provider tests use.
Add src/response (lifecycle, frame-loop, timing, deliver, proxy) as the one place that decides how a reply is paced, when a client disconnect stops it, and how teardown is journaled. Move the SSE, NDJSON and AWS event-stream codecs and writers onto it while keeping the exported writers on the 508dd9b contract, detect a proxy client hang-up on an actual close, and remove the delay() abort listener when its timer fires.
Serve OpenAI Chat Completions and Anthropic Messages through the shared delivery foundation, so a client disconnect stops the reply and is journaled as client aborted, and honor nonStreamingTimingAllProviders on their non-streaming replies.
Move Responses, Gemini and Vertex, Gemini Interactions, Cohere, Bedrock InvokeModel and Converse, Ollama chat and generate, and audio transcription onto the shared delivery foundation. A client disconnect now stops each reply and is journaled, record-mode transcription relays journal a client hang-up, and nonStreamingTimingAllProviders delays the non-streaming replies of every provider except transcription.
Treat a client FIN without a close frame as a WebSocket disconnect, and route Responses, Realtime and Gemini Live turns through wsLifecycle with one closed-socket rule, so a client close mid-turn is journaled as client aborted.
Note client disconnect handling and non-streaming timing on each provider page, the streaming-physics and error-injection pages, the fixtures reference and the write-fixtures skill. Add the CHANGELOG entry and regenerate the API report for the new Fixture field.
jpr5
force-pushed
the
feat/cross-provider-disconnect-timing
branch
from
October 24, 2026 00:35
8d34bfc to
6a35aff
Compare
jpr5
added a commit
that referenced
this pull request
Oct 24, 2026
Closes #549 ## Summary This PR adds raw passthrough fixtures. A fixture response of the form `{ raw: { wire, events?, json?, status?, headers?, done? } }` sends provider events or JSON exactly as written. aimock adds only the framing of the named wire. No overrides, reasoning gating, usage synthesis, provider shaping or drift checks apply to a raw fixture. **Per-wire framing.** Each wire uses the same bytes that the typed handlers write for that wire: - Named SSE (`event: <type>` then `data: <json>`): `openai-responses`, `anthropic`, `cohere`. - Data SSE (`data: <json>`): `openai-chat`, `gemini`, `gemini-interactions`. On `openai-chat`, the stream ends with `data: [DONE]` unless `raw.done` is `false`. - NDJSON: `ollama` (`/api/chat`) and `ollama-generate` (`/api/generate`). These are two wires because the frames of the two routes have different shapes. - AWS event stream: `bedrock-invoke` wraps each event as a `chunk` frame with base64 `bytes`. `bedrock-converse` uses the single key of each event as the frame's event type and its value as the payload. That gives 10 wires: `openai-responses`, `openai-chat` (including the OpenRouter and Azure routes), `anthropic`, `gemini` (including Vertex), `gemini-interactions`, `cohere`, `ollama`, `ollama-generate`, `bedrock-invoke` and `bedrock-converse`. The router offers a raw fixture only to the handler for its `raw.wire`. The handler supplies the wire, never the request body. When a raw fixture matches a request in every way except its wire, and no other fixture matches, the server log names both wires instead of returning a silent 404. **Stream vs JSON.** A streaming request gets `events` and a non-streaming request gets `json`. If the body that the request needs is missing, aimock answers with the native 500 error of that wire and the code `aimock_raw_mode_unavailable`. **Status and headers.** `status` defaults to 200 and must be 200-599, except 204 and 304. `headers` replace the defaults case-insensitively. If `headers` has no Content-Type, a stream gets the wire's own type and a non-streaming reply gets `application/json`. aimock rejects headers that it must set itself, because it frames the body. **Error codes.** An invalid raw fixture gets a 500 in the wire's own error shape with the code `aimock_raw_fixture_invalid`. Examples are a factory that returns `status: 99`, or a factory that returns a raw fixture for another wire. On Gemini, Ollama and OpenRouter the code is prefixed to the message. **Validation.** `--validate-on-load`, `addFixturesFromJSON` and `POST /__aimock/fixtures` all check raw fixtures with one shared checker: - the wire is known, and `events` or `json` is present; - the events are plain JSON objects; - the per-wire type and single-key rules pass; - `status` and the header names and values are valid; - `done` is a boolean, and only on `openai-chat`; - `customToolCalls` and `responsesBlocks` are not next to `raw`; - the fixture does not set its own misbehavior. Unknown keys inside `raw` and an empty `events` are warnings. `chunkSize` and `openRouterProcessing` on a raw fixture are warnings, because they are ignored. The same check runs again when a fixture is served, so fixtures added with `on()`, `addFixture()` or factories are checked too. **Misbehavior skip.** Model misbehavior faults skip raw fixtures. aimock skips a server-level fault, serves the raw reply, and records the fault as skipped in the journal. An `X-AIMock-Misbehavior` header returns 501 `aimock_misbehavior_not_applicable`. A raw fixture that sets its own misbehavior is an invalid fixture: load validation reports it, and when it is served it gets `aimock_raw_fixture_invalid`. **Timing rules.** `latency`, `streamingProfile`, `recordedTimings` and `replaySpeed` pace streamed raw events in the same way as typed frames. `truncateAfterChunks`, `disconnectAfterMs` and chaos also apply, and each raw event counts as one chunk. A non-streaming raw reply follows `nonStreamingTiming` like a typed fixture. `nonStreamingTiming: true` delays the reply on OpenAI Chat Completions only. `nonStreamingTimingAllProviders: true` delays it on every raw wire. aimock sizes the wait from the served JSON. **Error replies.** A raw reply with a status of 400 or more is never delayed, streaming or not. If the reply has a `json`, a streaming request gets that JSON instead of events, because provider APIs answer errors that way. Error replies are recorded in the journal. **WS Responses.** `/v1/responses` over WebSocket answers a raw fixture with an error event with the code `aimock_raw_mode_unavailable` and the message "raw fixtures are not yet supported on the WebSocket transport". Before this change, it answered with the generic unknown-type error. **OpenRouter failover.** In an OpenRouter `models[]` fallback chain, an `openai-chat` raw fixture with a status of 400 or more fails over to the next model. A typed error fixture with the same status does the same. ## Compatibility - **Additive only.** The feature is opt-in. A response is raw only if `raw` is a non-null, non-array object and no other response type's marker key (`content`, `toolCalls`, `error`, `json`, and so on) is present. A typed fixture with a stray `raw` key is still served, and validated, exactly as before. - **Typed fixtures unchanged.** Existing typed fixtures produce output byte-identical to main: an offline probe compared 321 cases (status, headers except `date`, and body) on the pre-review build, and the no-regression snapshot plus the #579 byte goldens pass at the final head. The no-regression suite (`raw-no-regression.test.ts`, with a snapshot) is green on main and on this branch. - **API report.** The API report changes are additions only: - new exported types `RawResponse`, `RawWireId` and `WireId` (`WireId` was already declared, and is now exported); - `RawResponse` is added to the `FixtureResponse` and `FixtureFileResponse` unions; - a new `@internal` `wire` field on `MatchOptions`. `isRawResponse` is internal and not exported. ## Red-green evidence Each wire was reproduced against a real aimock server, with and without `--validate-on-load`, and with the real provider SDK tests. Before the change, every raw fixture request got a 500 with the body `Fixture response did not match any known type`. After the change, the authored events or JSON are served with the wire's framing. | Wire | Before (curl, stream / non-stream) | After (curl, stream / non-stream) | SDK tests before | SDK tests after | | --- | --- | --- | --- | --- | | openai-responses | 500 / 500, "did not match any known type" | 200 `text/event-stream` / 200 `application/json` | 16 failed, 1 passed (17) | 17 passed (17) | | openai-chat | 500 / 500, "did not match any known type" | 200 `text/event-stream`, ends `data: [DONE]` / 200 `application/json` | 14 failed (14) | 14 passed (14) | | anthropic | 500 / 500, "did not match any known type" | 200 `text/event-stream` / 200 | 9 failed (9) | 9 passed (9) | | gemini, gemini-interactions | 500 / 500 on both, "did not match any known type" | 200 / 200 on both | 16 failed (16) | 16 passed (16) | | cohere | 500 / 500, "did not match any known type" | 200 `text/event-stream` / 200 | 21 failed (21), cohere and ollama together | 21 passed (21), cohere and ollama together | | ollama, ollama-generate | `/api/chat` 500, `/api/generate` 500, "did not match any known type" | `/api/chat` 200, `/api/generate` 200, `application/x-ndjson` | (see cohere row) | (see cohere row) | | bedrock-invoke, bedrock-converse | 500 / 500 on both, "did not match any known type" | 200 `application/vnd.amazon.eventstream` / 200 on both | 13 failed (13), the SDK reported UnknownError | 13 passed (13) | The split between `ollama` and `ollama-generate` has its own red-green. Before the split, the test "/api/generate gets the ollama-generate fixture in generate shape" failed (1 failed, 1 passed). After the split, it passes (2 passed). The full suite, typecheck, lint, format check, build, goldens/contract tests and the API report check all pass on this branch. ## Not in this PR - Raw fixtures over WebSocket Responses, and `record.raw` (recording raw fixtures). Both are in spec PR3. - Bedrock exception framing for raw error replies. Spec: raw SSE/JSON passthrough fixtures (Notion)
Merged
jpr5
added a commit
that referenced
this pull request
Oct 27, 2026
Release v1.47.0. Merging this PR publishes through `publish-release.yml`: npm, the git tag, the GitHub Release and Slack. ## Changelog ### Added - Fixture `citations` serve sources on Responses, Chat Completions, Claude and Gemini (#550, #578) - Fixture `files` serve generated images on Gemini and OpenAI Responses (#550, #578) - Raw passthrough fixtures send provider SSE events or JSON exactly as written (#580) - Opt-in `nonStreamingTimingAllProviders` delays non-streaming replies on every provider (#579) ### Fixed - Client disconnects stop and journal every LLM reply; Docker image on Node 22.23.4 (#579, #576) The detailed notes from `[Unreleased]` are condensed into these bullets. The full detail is in the linked PRs. The drift-harness and CI changes (#583–#587) do not change what users see, so they are left out. ## Docs (separate commit) - The fixtures page gets a new **Citations and generated files** section: the fixture shape, the wire shape for each provider, the error codes, and override-table and provider-matrix rows. The Responses, Chat Completions, Claude and Gemini pages link to it. - What's New has three 1.47.0 cards: citations and generated images, raw passthrough, and stream realism (disconnects and timing on every provider). The two 1.46.0 cards are merged into one. The GPT-Live card is removed because it shipped in 1.43.0, more than a month ago. - The README has new feature bullets for citations and files, raw passthrough and client disconnects. The Streaming Physics bullet now mentions `nonStreamingTimingAllProviders`. - The competitive matrix has three new hand-reviewed rows: provider-native citations, model-generated images, and stopping on client disconnect. Each competitor cell was checked against a shallow clone of that competitor's source on 2026-10-26, and an HTML comment on each row records the evidence. The rows are added to `HAND_ROWS`, so the weekly scan never rewrites them. The homepage ND count in the test is now 56. ## Version carriers (`git grep -F 1.46.0`) `package.json`, `charts/aimock/Chart.yaml` appVersion, `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` (`^1.47.0`), the `docs/index.html` banner, the `packages/aimock-pytest/README.md` default, and `_version.py` `AIMOCK_VERSION`. These are not bumped: the CHANGELOG history, the `version-note` labels for 1.46.0 features, and `pyproject.toml`. ## Local verification - format:check, lint, typecheck, build, api:report with no `etc/` diff, test:exports, the docs and matrix tests, and the full `pnpm test`. - The citations and files wire shapes were exercised against the built server for Chat, Responses, Claude and Gemini, including the 500 for `files` on Chat Completions. - Screenshots of What's New, the matrix and the fixtures section at 375, 430 and 1440 px.
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.
Summary
When a client hangs up, aimock now stops delivering the reply on every provider and every transport, and the journal records it. Before this change, only some paths noticed the hang-up. The others kept writing into a dead socket, or kept waiting out a delay, and journaled the turn as a normal 200.
interrupted: trueand the reason"client aborted". When the client left before any byte of the reply reached its socket, the journaled status is 0, because the client never got a status line.disconnect), the reason is"disconnect", so you can tell your client's behaviour apart from aimock's.nonStreamingTimingAllProviders. It applies the non-streaming reply delay on every HTTP provider: OpenAI Chat, Responses, Anthropic, Gemini, Vertex, Gemini Interactions, Bedrock Invoke and Converse, Ollama chat and generate, and Cohere. The existingnonStreamingTimingfield is unchanged and still applies only where it did before.Compatibility
Additive only.
nonStreamingTimingAllProviders?: boolean, onFixtureandFixtureFileEntry. No other public surface changes.Red-green evidence
Each row was run against the real aimock CLI with curl or a raw WebSocket client, then the journal was read. RED is the pre-change build. GREEN is this branch.
nonStreamingTimingAllProviders: true. The figure is the time to the reply.WebSocket FIN without a close frame (WS Responses): RED, the server kept the socket open for the full 30 s probe and kept reading. GREEN, the server closed it within 0.1 ms of the FIN.
In every GREEN run, the healthy stream, the default non-streaming reply,
nonStreamingTiming: true, and the 429 error reply have the same bytes (same body hash) and the same timing as RED. The 429 is sent in about 5 ms in both. The streamed bytes before each abort are also identical between RED and GREEN.Not in this PR
headersSent.writableFinishededge).Spec: cross-provider disconnect and timing (Notion)