fix(dwctl): refuse native thinking fields on Responses (DW-843)

Merged
CR
2d ago
+171-5

Why

/v1/responses accepted provider-native thinking fields and silently ignored them, while /v1/chat/completions refuses the same fields with a 400. A request with top-level reasoning_effort, thinking: {type: disabled} or chat_template_kwargs: {enable_thinking: false} returned 200, and the model still reasoned.

onwards already has the Responses refusal list (parse_reasoning_request for /responses), but it never saw these fields. The edge translator rebuilds the request as Chat Completions and keeps only reasoning.effort (as reasoning_effort) and nvext, so every other unknown field was dropped before onwards checked the body.

What changes

  • Translator: OpenResponses::translate_request runs onwards::reasoning::validate_canonical_reasoning("/responses", …) on the request's unknown fields plus reasoning before converting it. Every Responses request goes through this in both strict and non-strict mode, including flex, background and batch rows when the daemon dispatches them.
  • Error envelope: a new TranslationError::Reasoning keeps the onwards error's status, param and code. A new ProtocolTranslator::error_for_code hook (which defaults to error_for_param) lets the Responses translator put code in its envelope. The refusal therefore matches Chat Completions: type: invalid_request_error, code: unsupported_parameter, param naming the field, and the message Unsupported parameter '<field>'; use 'reasoning.effort'.
  • Before acceptance: flex, background and realtime background: true requests are accepted (or queued) before translation runs. The inference middleware runs the same check on every Responses request first, so the caller gets the 400 instead of a 202 or a failed response object later.
  • Side effect: an invalid reasoning.effort (for example "ultra") is now refused as reasoning.effort. Before, it was named reasoning_effort after translation.

Batch uploads already refused these fields: files.rs and batches.rs validate each line against its own /v1/responses URL.

Testing

Ran locally:

  • cargo test -p dwctl --lib inference::translation: 166 passed, including the new native_thinking_fields_are_refused_before_translation, an_invalid_reasoning_effort_is_named_by_its_responses_path and canonical_reasoning_and_nvext_still_translate.
  • cargo test -p dwctl --lib responses_refuse_native_thinking_fields_before_acceptance: passed (flex, flex with background, background, and background: true with omitted, auto and priority tiers).
  • With the translator check removed, the new translator test fails because the request reaches the upstream handler.
  • cargo fmt -p dwctl and cargo clippy --package dwctl --all-features --no-deps. The only warnings are two that were already in profiling/mod.rs. The ZDR payload-logging guard passed.

The full suite is left to CI.