Skip to content

feat(server): emit a reasoning alias next to reasoning_content on chat completion messages and deltas #1308

Closed

Description

@inureyes

Summary

Chat completions surface model thinking only under reasoning_content (the DeepSeek / vLLM spelling), on both the non-streaming message and the streaming delta. A second family of clients (OpenRouter-style consumers, and UIs that follow it) reads reasoning instead and renders nothing for mlxcel. The server already accepts both spellings on input (Message.reasoning with the reasoning_content alias). The change mirrors that on output: every place that writes reasoning_content also writes an identical reasoning field, omitted when absent, behind a server flag that defaults to on.

Current behavior

  • src/server/types/stream.rs:34:

    pub reasoning_content: Option<String>,

    is the only reasoning field on Delta; ChatCompletionChunk::reasoning_content (src/server/types/stream.rs:163) fills it.

  • src/server/types/response.rs:123: ChatMessage.reasoning_content: Option<String> is the only reasoning field on the non-streaming message; with_reasoning_content (src/server/types/response.rs:296) sets it.

  • Input side already accepts both: src/server/types/request.rs:555 declares #[serde(alias = "reasoning_content")] pub reasoning: Option<String> and the test message_reasoning_field_and_alias_round_trip (src/server/types/request.rs:1245) locks it.

Expected behavior

Non-streaming:

{"role":"assistant","content":"4","reasoning_content":"2+2","reasoning":"2+2"}

Streaming delta for a reasoning chunk:

{"delta":{"reasoning_content":"2+","reasoning":"2+"}}

Both fields are omitted together when there is no reasoning. The Responses API (response.reasoning_text.delta) and the Anthropic thinking blocks are unchanged. A server flag --reasoning-alias-field <none|reasoning> (env MLXCEL_REASONING_ALIAS_FIELD, default reasoning) lets an operator turn the duplicate off for byte-budget-sensitive deployments.

Implementation plan

  1. src/server/types/stream.rs: add #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option<String> to Delta; in ChatCompletionChunk::reasoning_content set both fields from the same text when the alias is enabled. Every other constructor sets reasoning: None.
  2. src/server/types/response.rs: add the same field to ChatMessage; with_reasoning_content fills both.
  3. src/server/config.rs: add pub reasoning_alias_field: ReasoningAliasField (enum { None, Reasoning }, default Reasoning); plumb from src/bin/mlx_server.rs and src/server/cli_input.rs like enable_props_endpoint. Thread the flag into the two constructors above through their callers in src/server/routes/chat.rs (both the streaming reasoning emit and the non-streaming with_reasoning_content site) and src/server/router_front.rs (the disaggregated front emits the same chunks at emit_filtered).
  4. Docs: note the alias in the chat-completions section of the server docs and in docs/responses-api.md where reasoning_content is described.

Validation

(a) Unit tests:

  • src/server/streaming_tests.rs: reasoning_chunk_carries_both_reasoning_fields (serialize a reasoning chunk, assert both keys present and equal; assert a content chunk has neither).
  • src/server/types/response.rs tests: with_reasoning_content_sets_alias_field and message_without_reasoning_omits_both_fields.
  • src/server/config_tests.rs: reasoning_alias_field_none_suppresses_alias.

(b) Real checkpoint: mlx-community/Qwen3-4B-4bit.

./target/release/mlxcel-server -m models/qwen3-4b-4bit --port 8080
curl -sN localhost:8080/v1/chat/completions -H 'content-type: application/json' -d '{"model":"q","messages":[{"role":"user","content":"What is 17*23? Think briefly."}],"stream":true,"max_tokens":128}' | grep -c '"reasoning":'

Acceptance: the count is greater than zero and equals the number of chunks that carry reasoning_content; the non-streaming variant returns a message where .reasoning == .reasoning_content; with --reasoning-alias-field none the count is zero.

Acceptance criteria

  • Streaming deltas and non-streaming messages carry reasoning alongside reasoning_content, omitted together when absent.
  • --reasoning-alias-field none suppresses the alias.
  • Unit tests above added; real-checkpoint check passes.
  • cargo test --workspace --profile test-fast --features metal,accelerate passes
  • cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all -- --check pass

Out of scope

  • Emitting reasoning_details arrays or signature fields.
  • Changing the Responses or Anthropic reasoning surfaces.

Activity

  1. added
    type:enhancementNew features, capabilities, or significant additions
    area:inferenceGeneration, sampling, decoding (incl. speculative, DRY)
    on Aug 22, 2026
  2. self-assigned this
    on Aug 31, 2026
  3. added a commit that references this issue on Sep 6, 2026
    e7a232e
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:inferenceGeneration, sampling, decoding (incl. speculative, DRY)modeltype:textText-only language modelpriority:lowLow prioritystatus:doneCompletedtype:enhancementNew features, capabilities, or significant additions

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions