feat: move the OpenAI Responses API into the dwctl edge (COR-519 + COR-536 + COR-522) - #1317
feat: move the OpenAI Responses API into the dwctl edge (COR-519 + COR-536 + COR-522)#1317JoshC8C7 wants to merge 16 commits into
Conversation
… from onwards yet.
Move the Responses edge translation from the outermost layer to inner of the outlet, and make the inference middleware outermost, so GET /v1/responses/{id} and background mode work. The outlet now persists the translated Responses object (what GET reads); the control plane (id minting, previous_response_id hydration, background/flex routing) owns the Responses lifecycle in the inference middleware; and the translator becomes a pure converter that stamps the platform tracking id so client and stored ids match. Also adds /messages to should_intercept and a POST-then-GET test for the /responses path.
Deploying control-layer with
|
| Latest commit: |
b564c65
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://51758458.control-layer.pages.dev |
| Branch Preview URL: | https://josh-responses-edge-in-dwctl.control-layer.pages.dev |
There was a problem hiding this comment.
Pull request overview
This PR fixes OpenAI Responses API edge-translation behavior in dwctl by reordering middleware so the inference middleware and outlet see the raw /responses request and persist the translated Responses-shaped object, restoring correct GET /v1/responses/{id} retrieval and background handling.
Changes:
- Reorders the onwards middleware stack to
inference_mw → outlet → translation → … → onwards, so control-plane fields are handled before translation and persisted responses match client IDs. - Ports Responses request/response/streaming translation logic into
dwctl/src/inference/translation/responses/*and threads the fusillade tracking ID through translation. - Adds an end-to-end POST-then-GET test to ensure the client-facing Responses ID resolves via
GET.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| dwctl/src/lib.rs | Reorders middleware layering and wires in OpenResponses translator in strict mode. |
| dwctl/src/inference/middleware.rs | Moves previous_response_id hydration into inference middleware and expands intercept paths to include /messages. |
| dwctl/src/inference/translation/mod.rs | Updates translation trait signatures to accept original request bytes + tracking ID (docs need correction). |
| dwctl/src/inference/translation/middleware.rs | Captures original request bytes and x-fusillade-request-id for response/stream translation. |
| dwctl/src/inference/translation/responses/mod.rs | Adds OpenResponses translator (detect should be header-gated). |
| dwctl/src/inference/translation/responses/request.rs | Converts Responses requests into Chat Completions requests (pure conversion). |
| dwctl/src/inference/translation/responses/response.rs | Converts Chat Completions responses into Responses objects and stamps tracking IDs. |
| dwctl/src/inference/translation/responses/streaming.rs | Reframes Chat Completions SSE into Responses streaming events. |
| dwctl/src/inference/translation/responses/types.rs | Adds Open Responses schema types copied from onwards (strict schemas + helpers). |
| dwctl/src/inference/translation/responses/util.rs | Adds shared utility helpers (usage mapping, reasoning merge, scrub ID fields). |
| dwctl/src/inference/translation/responses/hydrate.rs | Adds previous_response_id hydration helper using ResponseStore. |
| dwctl/src/inference/translation/anthropic/mod.rs | Adapts Anthropic translator to widened trait signatures. |
| dwctl/src/inference/translation/anthropic/models.rs | Adapts Anthropic models translator to widened trait signatures. |
| dwctl/src/test/responses.rs | Adds POST-then-GET integration test for client-facing Responses ID resolution. |
| Cargo.toml | Adds a local [patch.crates-io] override for onwards (must not ship). |
| Cargo.lock | Reflects the local onwards path patch (missing registry source/checksum). |
| //! Anthropic Messages (`/v1/messages`) is the first implementation and is purely | ||
| //! synchronous. The core transform stays sync and pure; a translator that needs | ||
| //! async, stateful work opts in via the [`ProtocolTranslator::pre_request`] / | ||
| //! [`ProtocolTranslator::post_response`] hooks, which the middleware awaits | ||
| //! around the pure translation. These brackets are how the planned OpenAI | ||
| //! Responses translator will resolve `previous_response_id` (hydration) and | ||
| //! persist the produced object, absorbing the translation half of the onwards | ||
| //! adapter. Multi-step tool-loop orchestration is still deliberately NOT part of | ||
| //! this layer. |
There was a problem hiding this comment.
Fixed. That paragraph described a design that got replaced: the pre_request/post_response hooks were removed when the layer was repositioned, and the trait is now pure and synchronous.
The module doc now states that explicitly and says where the stateful work actually lives - control-plane routing, tracking-id minting and previous_response_id hydration in the inference middleware, persistence in the outlet, both outer to this layer - and why the translator staying pure is what allows it to sit inside them.
Also corrected the opening line in the same file, which still claimed this middleware is the OUTERMOST Tower layer. It is not: it sits inside the inference middleware and the outlet.
| fn detect(&self, path: &str, _headers: &HeaderMap) -> bool { | ||
| // `/v1/responses` is owned solely by the Responses API; no header gate. | ||
| path.ends_with("/responses") | ||
| } |
There was a problem hiding this comment.
Good catch - fixed, though with a method gate rather than a content-type one.
The translation middleware had no method gate at all, so a GET/DELETE to a matching path (e.g. /v1/responses) was claimed by the translator and then rejected with a 400 from the body parse, instead of falling through to the real routing and its 404/405. It now returns early for anything that is not a POST, which mirrors should_intercept in the inference middleware (POST-gated for exactly this reason).
I did not add a content-type gate: a POST with a valid JSON body but no Content-Type header should still translate, and a POST whose body genuinely is not a Responses request should still get the 400 it gets today. Covered by a new test, non_post_request_is_not_intercepted.
…wctl (COR-536) With Responses translation placed correctly at the dwctl edge, onwards' Responses adapter is dead and the server-side multi-step tool loop is retired (COR-517). Removes the warm-path loop dispatch, the engine loop-driver, the MultiStepStore impl + loop storage helpers, the multi-step processor wiring, and HttpToolExecutor - keeping flex/background/GET, the storage substrate, and the tool-injection primitives. Relocates ResponseStore/NoOpResponseStore/StoreError from onwards into dwctl (its only remaining user). Pairs with the onwards deletion PR.
dwctl's inference_middleware now strips client-supplied id/completion_id/response_id keys from the request body before it re-serialises, so the scrub (onwards PR #240) happens where dwctl owns the single parse-and-shape. Exact-key removal preserves previous_response_id and every other extension. Removes the dead ported scrub from responses/util.rs + types.rs. Lets onwards forward the bytes verbatim.
# Conflicts: # Cargo.lock # dwctl/Cargo.toml # dwctl/src/inference/middleware.rs
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 32 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
Cargo.toml:9
- The workspace uses a
[patch.crates-io] onwards = { path = "../onwards" }override, but the parent directory in this repo checkout does not contain anonwards/crate. This will break CI/builds for anyone who doesn't have a sibling repo checked out locally. Prefer landing this PR with a normal crates.io dependency (or keep the patch in a local-only workflow, not committed).
# TEMPORARY (COR-519): test the local onwards edge-Responses changes together
# with dwctl before either PR lands. Remove before opening the dwctl PR; the
# onwards change ships as its own release and dwctl bumps the version dep.
[patch.crates-io]
onwards = { path = "../onwards" }
dwctl/src/inference/translation/mod.rs:33
- This module-level doc comment says translators can opt into async/stateful work via
ProtocolTranslator::pre_request/post_responsehooks, but theProtocolTranslatortrait in this file doesn't define those methods, and the Responses control-plane work is implemented outside the translator (inference middleware + outlet). The comment should be updated to reflect the actual API and current implementation.
//! Anthropic Messages (`/v1/messages`) is the first implementation and is purely
//! synchronous. The core transform stays sync and pure; a translator that needs
//! async, stateful work opts in via the [`ProtocolTranslator::pre_request`] /
//! [`ProtocolTranslator::post_response`] hooks, which the middleware awaits
//! around the pure translation. These brackets are how the planned OpenAI
| // OpenAI requires `additionalProperties: false` in strict mode. | ||
| let mut params = parameters.clone(); | ||
| if let Some(obj) = params.as_object_mut() | ||
| && !obj.contains_key("additionalProperties") | ||
| { | ||
| obj.insert("additionalProperties".to_string(), serde_json::Value::Bool(false)); | ||
| } |
There was a problem hiding this comment.
Correct that the code and the comment disagreed, but I have deliberately kept the behaviour and fixed the comment instead.
This function is a verbatim port of onwards' OpenResponsesAdapter::convert_tools (adapter.rs:684-723) - the unconditional insert and the misleading "in strict mode" comment both came across as-is. Narrowing it to strict == true here would make the dwctl edge send a different tool schema than the onwards adapter does today for explicitly non-strict tools, which is a real behaviour change for anyone relying on the current shape. The point of this PR is to move Responses translation without changing what goes upstream, so I would rather not smuggle that in.
Scope note: strict defaults to true in the Responses schema (default_strict), so this only differs when a caller explicitly sends "strict": false.
The comment now says what the code actually does and why it is left alone. Happy to change the behaviour as a separate, visible change if you think the current shape is wrong.
# Conflicts: # Cargo.lock # dwctl/Cargo.toml
The blanket POST gate added for the /responses review comment also blocked AnthropicModels, which deliberately claims GET /models to normalise x-api-key into Authorization. That 401'd Anthropic model discovery. Move the gate behind translator.translates_request_body(), so it only guards translators that actually deserialise a body. Defaults to true; AnthropicModels overrides it to false.
# Conflicts: # Cargo.lock # dwctl/Cargo.toml
The multi-step feature on onwards pulls in the optional fusillade dep, which the merged lockfile was missing.
feat: move the OpenAI Responses API into the dwctl edge (COR-519 + COR-536 + COR-522)
Moves OpenAI Responses translation into the dwctl edge, placed so the whole
Responses lifecycle keeps working, and retires the dwctl-side machinery this makes
dead. onwards is left alone: nothing is removed there.
1. Translate Responses at the dwctl edge (COR-519)
/v1/responsesis now handled by dwctl's edge translation framework (the sameshell as the Anthropic Messages ingress): the Responses request is translated to
Chat Completions, forwarded through the unchanged proxy path, and the reply
translated back into a Responses object.
The translated request still arrives at onwards on the
/responsesroute (axumcannot re-trigger route matching from a URI rewrite through a nest), so onwards
needs to recognise it - that is the one-line-ish dispatch in onwards
#274, already released.
2. Place the translation correctly (the important part)
The first cut put the translator at the OUTERMOST layer, copying the stateless
Anthropic pattern. That is wrong for Responses, which is a stored-object API with
an async control plane (
background, flex,GET /v1/responses/{id}):GET404'd: the outlet writes the row GET reads, but it sat inner to thetranslator, so on the response path it captured the chat body, and the id the
client got (
resp_<chat id>) never matched the row's tracking id.backgroundstopped engaging: the path rewrite hid the flag from the router.Fix: reorder to
inference_mw -> outlet -> translation -> cache -> ... -> onwards.The router now sees the raw
/responsesrequest (sobackground/service_tierrouting and
previous_response_idhydration work), the outlet captures thetranslated Responses object (so
GETreturns the right shape), and the translatorbecomes a pure converter that stamps the tracking id. This restores exactly what
the outlet saw in the old onwards-side placement.
3. Retire the dwctl server-side tool loop, own ResponseStore (COR-536)
The server-side multi-step tool loop is retired (COR-517: unpublicised, unused).
Removed from dwctl: the loop dispatch, the engine loop-driver
(
transition/assembly/loop_http_client/processor), theMultiStepStoreimpl + loop storage helpers, the multi-step processor wiring, and
HttpToolExecutor. Kept: flex, background,GET, the storage substrate, and thetool-injection primitives.
ResponseStorenow lives in dwctl (inference/response_store.rs), which is itsonly remaining user. onwards keeps its own copy of the trait; dwctl simply no
longer depends on it.
Server-side tool execution on
/responsesstops (the model'stool_callsgo backto the client); client-side tool calling, flex, background, and GET are unaffected.
4. Scrub client-supplied request ids at the edge (COR-522)
dwctl's
inference_middlewarenow strips client-suppliedid/completion_id/completionId/response_id/responseIdfrom the requestbody, matching what onwards PR #240 does. Exact-key removal, so
previous_response_idand every other extension survive.Note this is additive, not a relocation: onwards still performs its own scrub, so
the keys are now removed on both sides. That is harmless (idempotent removal of
the same five keys) and means the protection holds whichever path a request takes.
Review fixes
GET/DELETEto abody-translating path falls through to real routing (and its 404/405) instead of
being rejected by the body parse. The gate is per-translator, behind
ProtocolTranslator::translates_request_body():AnthropicModelsdeliberatelyclaims
GET /modelsonly to normalise auth, and a blanket POST check silently401'd Anthropic model discovery. Both cases are pinned by tests.
translation/mod.rs(it described removedpre_request/post_responsehooks and still called this the outermost layer).[patch.crates-io]; onwards is a plain versioneddependency resolved from the registry.
convert_toolsstill appliesadditionalProperties: falseregardless ofstrict. That is a verbatim port of onwards' adapter; narrowing it would changethe schema sent upstream for explicitly non-strict tools, so it is deliberately
left alone and the misleading comment fixed instead.
Testing
just lint rustclean (fmt, clippy, ZDR guard, SQLx).cargo test -p dwctl --lib: 1816 passed. The residual failures are sqlxtest-harness DB contention under the parallel suite - the failing set differs
between runs and every one passes in isolation (verified individually).
test_responses_post_then_get_by_client_id: POST/ai/v1/responsesthen GET bythe client's own id - verified it FAILS at the broken id (404) and PASSES with
the placement fix.
non_post_request_is_not_interceptedandget_is_still_translated_for_header_only_translator.Known gaps
Flex +
backgroundPOST-then-GET aren't covered end-to-end: they complete on thefusillade daemon, which dispatches back through the dwctl loopback (
.../ai), andthe in-memory
axum_testtransport has no real port for that loopback. The daemonis exercised by
sla.rsbut only against a direct upstream endpoint. Covering the/responsesloopback needs a real-port e2e harness - logged as a follow-up; theinvariant it relies on is documented at the flex enqueue site.
Separately,
previous_response_idhydration is broken by a pre-existing defect indetail_to_response_object(it rebuilds a schema-invalid Responses object, so thestrict deserialize fails). That predates this PR - the same lossy path existed on
main - and is tracked as COR-549 rather than fixed here.
Cross-repo note
The onwards side is already merged and released: onwards #274 shipped in
v0.35.5, which is the version this PR depends on. No unreleased dependency and
no merge-ordering constraint. That change is small and additive - it teaches
onwards'
/responsesroute to recognise an already-translated request and hand itto the chat handler. Nothing was removed from onwards: the Open Responses adapter,
schemas, passthrough mode and the tool loop all remain.