Skip to content
19 changes: 19 additions & 0 deletions docs/fern/pages/reference/components/frontend-configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -922,6 +922,25 @@ Before conversion, the frontend validates these
- `top_k` must fit the backend's signed 32-bit field (at most 2,147,483,647).

For `system`, `metadata`, `tool_choice`, and `thinking`, `null` is treated as omission.
These checks validate admission. Backend forwarding and enforcement are described below.

#### Reasoning Controls

Dynamo forwards
`output_config.effort` (`low`, `medium`, `high`, `xhigh`, or `max`) as the existing
`reasoning_effort` field. Enabled-thinking `budget_tokens` becomes
`thinking_token_budget`, which feeds the existing thinking stop condition. Explicit
`adaptive` mode continues to let the model decide whether to think, including when an
API client such as Claude Code also sends effort `high`. `between_tools` maps to
no up-front template thinking, including when effort is supplied; it does not enable
model-specific progress updates or hosted tool execution.

Forwarding does not guarantee identical behavior across models. Effort needs a model
and chat template that understand the selected grade. Budget enforcement needs backend
support: vLLM uses its thinking-budget sampling field when available; SGLang requires
`--enable-strict-thinking` and a reasoning parser. Other backend configurations
may ignore the budget. This endpoint does not implement Anthropic's hosted reasoning
or interleaved-turn execution lifecycle. Omitting controls preserves the model defaults.

### Infrastructure

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ Dynamo v1.4.0 includes contributions from 127 developers, 29 of whom made their
- [@joeltg](https://github.com/joeltg) shared one ZMQ context across all event-plane sockets ([#11246](https://github.com/ai-dynamo/dynamo/pull/11246))
- [@ssojrani-wq](https://github.com/ssojrani-wq) parsed config.json as JSON5 when deriving max context length ([#11445](https://github.com/ai-dynamo/dynamo/pull/11445))
- [@renflowerz](https://github.com/renflowerz) restored trace context across the Python chat-processor boundary ([#11480](https://github.com/ai-dynamo/dynamo/pull/11480))
- [@hdimmfh](https://github.com/hdimmfh) aligned Kubernetes installation examples with the shipped release ([#11486](https://github.com/ai-dynamo/dynamo/pull/11486))
- [@myeongsu-koo](https://github.com/myeongsu-koo) aligned Kubernetes installation examples with the shipped release ([#11486](https://github.com/ai-dynamo/dynamo/pull/11486))
- [@RomaA2000](https://github.com/RomaA2000) added GPT-OSS-120B vLLM recipes for B200 and H200 ([#11506](https://github.com/ai-dynamo/dynamo/pull/11506))
- [@aniskumar-nv](https://github.com/aniskumar-nv) added Python-loadable frontend route extensions ([#11626](https://github.com/ai-dynamo/dynamo/pull/11626))
- [@alexeldeib](https://github.com/alexeldeib) honored reasoning effort in chat templates ([#11691](https://github.com/ai-dynamo/dynamo/pull/11691))
Expand Down
25 changes: 17 additions & 8 deletions lib/llm/src/http/service/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -602,11 +602,13 @@ async fn anthropic_messages(
0
};

// Check if the Anthropic request explicitly disabled thinking.
let thinking_explicitly_disabled = orig_request
.thinking
.as_ref()
.is_some_and(|t| t.thinking_type == "disabled");
// Explicit adaptive/between-tools modes must not force an up-front thinking prefix.
let thinking_defers_prompt_injection = orig_request.thinking.as_ref().is_some_and(|t| {
matches!(
t.thinking_type.as_str(),
"disabled" | "adaptive" | "between_tools"
)
});

// Convert Anthropic request -> UnifiedRequest -> Chat Completion request
let unified_request: UnifiedRequest = orig_request.try_into().map_err(|e: anyhow::Error| {
Expand Down Expand Up @@ -640,8 +642,8 @@ async fn anthropic_messages(
return Err(AnthropicHandlerError::validation(error.message())
.into_marked_response(&mut inflight_guard));
}
// When a reasoning parser is configured and the client hasn't explicitly
// disabled thinking, assume the model's chat template will inject `<think>`.
// When a reasoning parser is configured, infer an injected thinking prefix
// unless the client disables thinking or delegates its timing to the model.
//
// Two things must be aligned:
// 1. chat_template_args must include enable_thinking=true so the backend's
Expand All @@ -658,7 +660,7 @@ async fn anthropic_messages(
// prompt_injected_reasoning accordingly. The Anthropic path bypasses the
// preprocessor, so we infer prompt injection from the reasoning parser config.
let prompt_injected_reasoning =
parsing_options.reasoning_parser.is_some() && !thinking_explicitly_disabled;
parsing_options.reasoning_parser.is_some() && !thinking_defers_prompt_injection;

if prompt_injected_reasoning {
let args = chat_request
Expand All @@ -675,6 +677,13 @@ async fn anthropic_messages(
.or_insert(serde_json::Value::Bool(false));
}

chat_request
.normalize_reasoning_template_args()
.map_err(|error| {
AnthropicHandlerError::validation(error.to_string())
.into_marked_response(&mut inflight_guard)
})?;

let mut request = context.map(|_req| chat_request);

// Anthropic requests are converted to the same chat request contract. Keep
Expand Down
56 changes: 52 additions & 4 deletions lib/llm/src/protocols/anthropic/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,45 @@ impl TryFrom<AnthropicCreateMessageRequest> for NvCreateChatCompletionRequest {
})
.transpose()?;

let reasoning_effort = req
.output_config
.as_ref()
.and_then(|config| config.get("effort"))
.filter(|effort| !effort.is_null())
.map(|effort| {
if !matches!(
effort.as_str(),
Some("low" | "medium" | "high" | "xhigh" | "max")
) {
anyhow::bail!("output_config.effort: must be low, medium, high, xhigh, or max");
}
serde_json::from_value(effort.clone()).map_err(anyhow::Error::from)
})
.transpose()?;
let thinking_token_budget = req
.thinking
.as_ref()
.and_then(|thinking| thinking.budget_tokens);
let thinking = req
.thinking
.as_ref()
.filter(|thinking| {
matches!(
thinking.thinking_type.as_str(),
"enabled" | "disabled" | "adaptive" | "between_tools"
)
})
.map(|thinking| {
// The existing worker contract can disable up-front thinking;
// model-specific progress updates are not implemented here.
let mode = if thinking.thinking_type == "between_tools" {
"disabled"
} else {
&thinking.thinking_type
};
serde_json::json!({"type": mode})
});

Ok(NvCreateChatCompletionRequest {
preserve_thinking: None,
responses_tools: None,
Expand All @@ -184,6 +223,7 @@ impl TryFrom<AnthropicCreateMessageRequest> for NvCreateChatCompletionRequest {
tool_choice,
parallel_tool_calls,
response_format,
reasoning_effort,
stream: Some(true), // Always stream internally
// Request cumulative usage on every chunk (not just the final
// one) so the Anthropic stream converter can stamp an
Expand All @@ -198,7 +238,11 @@ impl TryFrom<AnthropicCreateMessageRequest> for NvCreateChatCompletionRequest {
..Default::default()
},
common: CommonExt {
top_k: req.top_k.map(|k| k as i32),
top_k: req
.top_k
.map(i32::try_from)
.transpose()
.map_err(|_| anyhow::anyhow!("top_k: must be at most 2147483647"))?,
..Default::default()
},
nvext: crate::protocols::common::extensions::parse_nvext(req.nvext)?,
Expand All @@ -210,7 +254,7 @@ impl TryFrom<AnthropicCreateMessageRequest> for NvCreateChatCompletionRequest {
chat_template_args: req.thinking.as_ref().and_then(|thinking| {
let enabled = match thinking.thinking_type.as_str() {
"enabled" => true,
"disabled" => false,
"disabled" | "between_tools" => false,
_ => return None,
};
let mut args = std::collections::HashMap::new();
Expand All @@ -220,8 +264,8 @@ impl TryFrom<AnthropicCreateMessageRequest> for NvCreateChatCompletionRequest {
);
Some(args)
}),
thinking: None,
thinking_token_budget: None,
thinking,
thinking_token_budget,
media_io_kwargs: None,
return_tokens_as_token_ids: None,
unsupported_fields: Default::default(),
Expand Down Expand Up @@ -3177,6 +3221,10 @@ mod thinking_control_tests {
serde_json::from_value::<AnthropicCreateMessageRequest>(value).unwrap(),
)
.unwrap();
// Match the production handler: fold canonical thinking into template
// arguments before comparing the unaffected history and tool fields.
disabled.normalize_reasoning_template_args().unwrap();
assert!(disabled.thinking.is_none());
assert_eq!(
disabled.chat_template_args.as_ref().unwrap()["enable_thinking"],
false
Expand Down
7 changes: 2 additions & 5 deletions lib/llm/tests/anthropic_http_replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -804,7 +804,7 @@ async fn tool_result_round_trip_reaches_the_chat_engine() {
"model": MODEL,
"max_tokens": 2048,
"stream": false,
"thinking": {"type": "enabled", "budget_tokens": 1024},
"thinking": {"type": "adaptive"},
"tools": [tool("list_directory")],
"messages": [{"role": "user", "content": "List /tmp"}]
}),
Expand Down Expand Up @@ -939,10 +939,7 @@ async fn explicit_thinking_controls_reach_the_chat_engine() {
let mut expected = Vec::new();
for (thinking, enabled) in [
(None, None),
(
Some(json!({"type": "enabled", "budget_tokens": 1024})),
Some(true),
),
(Some(json!({"type": "adaptive"})), None),
(Some(json!({"type": "disabled"})), Some(false)),
] {
let mut body = json!({
Expand Down
Loading
Loading