From 6bd35353086e5b9a640a449c1ae4e5e7f51aee35 Mon Sep 17 00:00:00 2001 From: Sergei Razmetov Date: Wed, 24 Jun 2026 00:47:54 +0300 Subject: [PATCH] language_models: Set support_thinking in Responses API for OpenAI-compatible provider (#59213) # Objective Fix OpenAI-compatible reasoning/thinking support. Previously, `OpenAiCompatibleLanguageModel` never reported thinking support, so configured `reasoning_effort` values did not reliably reach the wire and Responses API reasoning state could miss `include: ["reasoning.encrypted_content"]`. Fixes #58289 Addresses #59207 for OpenAI-compatible Responses models configured with `reasoning_effort`. ## Solution - Treat a non-`none` configured `reasoning_effort` as OpenAI-compatible thinking support. - Expose common OpenAI-style effort levels in the agent UI, using the configured effort as the default. - Honor selected reasoning effort for OpenAI-compatible chat-completions requests. - Send `reasoning_effort: "none"` when thinking is disabled for a configured reasoning model. - Preserve native OpenAI behavior by continuing to use native model-specific supported efforts and `max_completion_tokens`. - Add an OpenAI-compatible `max_tokens_parameter` capability for endpoints that expect output limits as `max_tokens`. - Parse common streamed thinking fields (`reasoning` and `reasoning_content`). - Add provider setup UI and docs for configuring OpenAI-compatible reasoning models. ## Testing - `cargo fmt -p language_model_core -p agent_ui -p language_models` - `cargo test -p language_models provider::open_ai_compatible::tests --lib` - `cargo test -p language_models provider::open_ai::tests --lib` - `cargo test -p open_ai completion::tests --lib` - `cargo test -p agent_ui agent_configuration::add_llm_provider_modal::tests --lib` - `git --no-pager diff --check` ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable --- Release Notes: - Improved OpenAI-compatible provider setup for reasoning models. --------- Co-authored-by: Anant Goel --- .../add_llm_provider_modal.rs | 179 ++++++++- crates/edit_prediction/src/mercury.rs | 1 + .../edit_prediction_cli/src/openai_client.rs | 2 + .../src/language_model_core.rs | 32 ++ .../language_models/src/provider/open_ai.rs | 48 ++- .../src/provider/open_ai_compatible.rs | 358 +++++++++++++++++- .../language_models/src/provider/opencode.rs | 4 +- .../src/provider/vercel_ai_gateway.rs | 2 + crates/language_models/src/provider/x_ai.rs | 1 + .../src/language_models_cloud.rs | 4 +- crates/open_ai/src/completion.rs | 139 ++++++- crates/open_ai/src/open_ai.rs | 3 + crates/settings_content/src/language_model.rs | 3 + docs/src/ai/use-api-access.md | 66 ++++ 14 files changed, 799 insertions(+), 43 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 86e556e2e5..d1f281bbca 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -13,11 +13,12 @@ use language_models::provider::open_ai_compatible::{ }; use settings::{ AnthropicCompatibleAvailableModel, AnthropicCompatibleModelCapabilities, - AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, update_settings_file, + AnthropicCompatibleSettingsContent, OpenAiCompatibleSettingsContent, OpenAiReasoningEffort, + update_settings_file, }; use ui::{ - Banner, Checkbox, KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, - WithScrollbar, prelude::*, + Banner, Checkbox, ContextMenu, ContextMenuEntry, DropdownMenu, DropdownStyle, IconPosition, + KeyBinding, Modal, ModalFooter, ModalHeader, Section, ToggleState, WithScrollbar, prelude::*, }; use ui_input::InputField; use workspace::{ModalView, Workspace}; @@ -126,6 +127,9 @@ struct ModelCapabilityToggles { pub supports_parallel_tool_calls: ToggleState, pub supports_prompt_cache_key: ToggleState, pub supports_chat_completions: ToggleState, + pub supports_thinking: ToggleState, + pub interleaved_reasoning: ToggleState, + pub max_tokens_parameter: ToggleState, } struct ModelInput { @@ -133,6 +137,7 @@ struct ModelInput { max_completion_tokens: Entity, max_output_tokens: Entity, max_tokens: Entity, + reasoning_effort: OpenAiReasoningEffort, capabilities: ModelCapabilityToggles, } @@ -179,6 +184,8 @@ impl ModelInput { parallel_tool_calls, prompt_cache_key, chat_completions, + interleaved_reasoning, + max_tokens_parameter, .. } = OpenAiCompatibleModelCapabilities::default(); @@ -193,7 +200,11 @@ impl ModelInput { supports_parallel_tool_calls: parallel_tool_calls.into(), supports_prompt_cache_key: prompt_cache_key.into(), supports_chat_completions: chat_completions.into(), + supports_thinking: ToggleState::Unselected, + interleaved_reasoning: interleaved_reasoning.into(), + max_tokens_parameter: max_tokens_parameter.into(), }, + reasoning_effort: OpenAiReasoningEffort::Medium, } } @@ -223,14 +234,24 @@ impl ModelInput { cx, )?), max_tokens: parse_u64_field(&self.max_tokens, "Max Tokens", cx)?, - reasoning_effort: None, + reasoning_effort: { + if self.capabilities.supports_thinking.selected() { + Some(self.reasoning_effort) + } else { + None + } + }, capabilities: OpenAiCompatibleModelCapabilities { tools: self.capabilities.supports_tools.selected(), images: self.capabilities.supports_images.selected(), parallel_tool_calls: self.capabilities.supports_parallel_tool_calls.selected(), prompt_cache_key: self.capabilities.supports_prompt_cache_key.selected(), chat_completions: self.capabilities.supports_chat_completions.selected(), - interleaved_reasoning: false, + interleaved_reasoning: self.capabilities.supports_thinking.selected() + && self.capabilities.supports_chat_completions.selected() + && self.capabilities.interleaved_reasoning.selected(), + max_tokens_parameter: self.capabilities.supports_chat_completions.selected() + && self.capabilities.max_tokens_parameter.selected(), }, }) } @@ -438,7 +459,11 @@ impl AddLlmProviderModal { cx.emit(DismissEvent); } - fn render_model_section(&self, cx: &mut Context) -> impl IntoElement { + fn render_model_section( + &self, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { v_flex() .mt_1() .gap_2() @@ -465,11 +490,94 @@ impl AddLlmProviderModal { .models .iter() .enumerate() - .map(|(ix, _)| self.render_model(ix, cx)), + .map(|(ix, _)| self.render_model(ix, window, cx)), ) } - fn render_model(&self, ix: usize, cx: &mut Context) -> impl IntoElement + use<> { + fn render_open_ai_reasoning_settings( + &self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { + let model = &self.input.models[ix]; + let selected_effort = model.reasoning_effort; + let supports_thinking = model.capabilities.supports_thinking; + let supports_chat_completions = model.capabilities.supports_chat_completions; + let interleaved_reasoning = model.capabilities.interleaved_reasoning; + let weak_self = cx.weak_entity(); + + let effort_menu = ContextMenu::build(window, cx, move |mut menu, _window, _cx| { + for effort in OpenAiReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE { + let is_selected = effort == selected_effort; + let weak_self = weak_self.clone(); + menu.push_item( + ContextMenuEntry::new(effort.label()) + .toggleable(IconPosition::End, is_selected) + .handler(move |_window, cx| { + weak_self + .update(cx, |this, cx| { + this.input.models[ix].reasoning_effort = effort; + cx.notify(); + }) + .ok(); + }), + ); + } + + menu + }); + + v_flex() + .gap_1() + .child( + Checkbox::new(("supports-thinking", ix), supports_thinking) + .label("Supports thinking") + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.supports_thinking = *checked; + cx.notify(); + })), + ) + .when(supports_thinking.selected(), |parent| { + parent + .child( + v_flex() + .gap_1() + .child(Label::new("Default reasoning effort").size(LabelSize::Small)) + .child( + DropdownMenu::new( + ElementId::Name( + format!("reasoning-effort-selector-{ix}").into(), + ), + selected_effort.label(), + effort_menu, + ) + .style(DropdownStyle::Outlined) + .trigger_size(ButtonSize::Compact) + .full_width(true) + .aria_label("Default reasoning effort"), + ), + ) + .when(supports_chat_completions.selected(), |parent| { + parent.child( + Checkbox::new(("interleaved-reasoning", ix), interleaved_reasoning) + .label("Preserves thinking in chat history") + .on_click(cx.listener(move |this, checked, _window, cx| { + this.input.models[ix].capabilities.interleaved_reasoning = + *checked; + cx.notify(); + })), + ) + }) + }) + } + + fn render_model( + &self, + ix: usize, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement + use<> { let has_more_than_one_model = self.input.models.len() > 1; let is_open_ai = self.provider.is_open_ai(); let model = &self.input.models[ix]; @@ -558,6 +666,27 @@ impl AddLlmProviderModal { }, )), ) + .when( + model.capabilities.supports_chat_completions.selected(), + |parent| { + parent.child( + Checkbox::new( + ("max-tokens-parameter", ix), + model.capabilities.max_tokens_parameter, + ) + .label("Uses max_tokens for output limit") + .on_click( + cx.listener(move |this, checked, _window, cx| { + this.input.models[ix] + .capabilities + .max_tokens_parameter = *checked; + cx.notify(); + }), + ), + ) + }, + ) + .child(self.render_open_ai_reasoning_settings(ix, window, cx)) }), ) .when(has_more_than_one_model, |this| { @@ -663,7 +792,7 @@ impl Render for AddLlmProviderModal { .child(self.input.provider_name.clone()) .child(self.input.api_url.clone()) .child(self.input.api_key.clone()) - .child(self.render_model_section(cx)), + .child(self.render_model_section(window, cx)), ), ) .footer( @@ -888,6 +1017,19 @@ mod tests { model_input.capabilities.supports_chat_completions, ToggleState::Selected ); + assert_eq!( + model_input.capabilities.supports_thinking, + ToggleState::Unselected + ); + assert_eq!( + model_input.capabilities.interleaved_reasoning, + ToggleState::Unselected + ); + assert_eq!( + model_input.capabilities.max_tokens_parameter, + ToggleState::Unselected + ); + assert_eq!(model_input.reasoning_effort, OpenAiReasoningEffort::Medium); let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert!(parsed_model.capabilities.tools); @@ -895,6 +1037,9 @@ mod tests { assert!(!parsed_model.capabilities.parallel_tool_calls); assert!(!parsed_model.capabilities.prompt_cache_key); assert!(parsed_model.capabilities.chat_completions); + assert!(!parsed_model.capabilities.interleaved_reasoning); + assert!(!parsed_model.capabilities.max_tokens_parameter); + assert_eq!(parsed_model.reasoning_effort, None); }); } @@ -913,6 +1058,9 @@ mod tests { model_input.capabilities.supports_parallel_tool_calls = ToggleState::Unselected; model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; model_input.capabilities.supports_chat_completions = ToggleState::Unselected; + model_input.capabilities.supports_thinking = ToggleState::Unselected; + model_input.capabilities.interleaved_reasoning = ToggleState::Selected; + model_input.capabilities.max_tokens_parameter = ToggleState::Selected; let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert!(!parsed_model.capabilities.tools); @@ -920,6 +1068,9 @@ mod tests { assert!(!parsed_model.capabilities.parallel_tool_calls); assert!(!parsed_model.capabilities.prompt_cache_key); assert!(!parsed_model.capabilities.chat_completions); + assert!(!parsed_model.capabilities.interleaved_reasoning); + assert!(!parsed_model.capabilities.max_tokens_parameter); + assert_eq!(parsed_model.reasoning_effort, None); }); } @@ -938,6 +1089,10 @@ mod tests { model_input.capabilities.supports_parallel_tool_calls = ToggleState::Selected; model_input.capabilities.supports_prompt_cache_key = ToggleState::Unselected; model_input.capabilities.supports_chat_completions = ToggleState::Selected; + model_input.capabilities.supports_thinking = ToggleState::Selected; + model_input.capabilities.interleaved_reasoning = ToggleState::Selected; + model_input.capabilities.max_tokens_parameter = ToggleState::Selected; + model_input.reasoning_effort = OpenAiReasoningEffort::XHigh; let parsed_model = model_input.parse_open_ai_compatible(cx).unwrap(); assert_eq!(parsed_model.name, "somemodel"); @@ -946,6 +1101,12 @@ mod tests { assert!(parsed_model.capabilities.parallel_tool_calls); assert!(!parsed_model.capabilities.prompt_cache_key); assert!(parsed_model.capabilities.chat_completions); + assert!(parsed_model.capabilities.interleaved_reasoning); + assert!(parsed_model.capabilities.max_tokens_parameter); + assert_eq!( + parsed_model.reasoning_effort, + Some(OpenAiReasoningEffort::XHigh) + ); }); } diff --git a/crates/edit_prediction/src/mercury.rs b/crates/edit_prediction/src/mercury.rs index db41d896fe..e835b6ece8 100644 --- a/crates/edit_prediction/src/mercury.rs +++ b/crates/edit_prediction/src/mercury.rs @@ -141,6 +141,7 @@ impl Mercury { stream: false, stream_options: None, max_completion_tokens: None, + max_tokens: None, stop: vec![], temperature: None, tool_choice: None, diff --git a/crates/edit_prediction_cli/src/openai_client.rs b/crates/edit_prediction_cli/src/openai_client.rs index 3352fcf508..72b8e75e86 100644 --- a/crates/edit_prediction_cli/src/openai_client.rs +++ b/crates/edit_prediction_cli/src/openai_client.rs @@ -42,6 +42,7 @@ impl PlainOpenAiClient { stream: false, stream_options: None, max_completion_tokens: Some(max_tokens), + max_tokens: None, stop: Vec::new(), temperature: None, tool_choice: None, @@ -503,6 +504,7 @@ impl BatchingOpenAiClient { stream: false, stream_options: None, max_completion_tokens: Some(serializable_request.max_tokens), + max_tokens: None, stop: Vec::new(), temperature: None, tool_choice: None, diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 980a35ffbf..cb1f75b7fd 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -480,6 +480,38 @@ pub enum ReasoningEffort { XHigh, } +impl ReasoningEffort { + pub const OPENAI_COMPATIBLE_SELECTABLE: [Self; 5] = [ + Self::Minimal, + Self::Low, + Self::Medium, + Self::High, + Self::XHigh, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::Minimal => "Minimal", + Self::Low => "Low", + Self::Medium => "Medium", + Self::High => "High", + Self::XHigh => "Extra High", + } + } + + pub fn value(self) -> &'static str { + match self { + Self::None => "none", + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index e60a1f08cc..a67a8741fc 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -26,7 +26,8 @@ use ui_input::InputField; use util::ResultExt; pub use open_ai::completion::{ - OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, + ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + into_open_ai_response, }; const PROVIDER_ID: LanguageModelProviderId = OPEN_AI_PROVIDER_ID; @@ -258,7 +259,7 @@ fn default_thinking_reasoning_effort(model: &open_ai::Model) -> Option Option bool { + effort != open_ai::ReasoningEffort::None +} + +fn normalize_open_ai_response_thinking_effort( + request: &mut LanguageModelRequest, + model: &open_ai::Model, +) { + let selected_effort_is_supported = request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .is_some_and(|effort| { + open_ai_reasoning_effort_is_supported(effort) + && model.supported_reasoning_efforts().contains(&effort) + }); + + if !selected_effort_is_supported { + request.thinking_effort = None; + } +} + fn supports_selectable_thinking_effort(model: &open_ai::Model) -> bool { model.uses_responses_api() && model .supported_reasoning_efforts() .iter() - .any(|effort| *effort != open_ai::ReasoningEffort::None) + .any(|effort| open_ai_reasoning_effort_is_supported(*effort)) } fn supported_thinking_effort_levels(model: &open_ai::Model) -> Vec { @@ -291,18 +314,13 @@ fn supported_thinking_effort_levels(model: &open_ai::Model) -> Vec return None, - open_ai::ReasoningEffort::Minimal => ("Minimal", "minimal"), - open_ai::ReasoningEffort::Low => ("Low", "low"), - open_ai::ReasoningEffort::Medium => ("Medium", "medium"), - open_ai::ReasoningEffort::High => ("High", "high"), - open_ai::ReasoningEffort::XHigh => ("Extra High", "xhigh"), - }; + if !open_ai_reasoning_effort_is_supported(effort) { + return None; + } Some(LanguageModelEffortLevel { - name: name.into(), - value: value.into(), + name: effort.label().into(), + value: effort.value().into(), is_default: Some(effort) == default_effort, }) }) @@ -543,6 +561,7 @@ impl LanguageModel for OpenAiLanguageModel { request.speed = None; } if self.model.uses_responses_api() { + normalize_open_ai_response_thinking_effort(&mut request, &self.model); let request = into_open_ai_response( request, self.model.id(), @@ -567,6 +586,7 @@ impl LanguageModel for OpenAiLanguageModel { self.model.supports_parallel_tool_calls(), self.model.supports_prompt_cache_key(), self.max_output_tokens(), + ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, ); diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index da165a77b5..fb914a6f7a 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -5,9 +5,10 @@ use gpui::{AnyView, App, AppContext, AsyncApp, Entity, Task, Window}; use http_client::{CustomHeaders, HttpClient}; use language_model::{ AuthenticateError, IconOrSvg, LanguageModel, LanguageModelCompletionError, - LanguageModelCompletionEvent, LanguageModelId, LanguageModelName, LanguageModelProvider, - LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, - LanguageModelRequest, LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, + LanguageModelCompletionEvent, LanguageModelEffortLevel, LanguageModelId, LanguageModelName, + LanguageModelProvider, LanguageModelProviderId, LanguageModelProviderName, + LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, + LanguageModelToolSchemaFormat, RateLimiter, }; use open_ai::{ ResponseStreamEvent, @@ -258,6 +259,79 @@ impl OpenAiCompatibleLanguageModel { } } +fn default_thinking_reasoning_effort(model: &AvailableModel) -> Option { + model + .reasoning_effort + .filter(|effort| *effort != open_ai::ReasoningEffort::None) +} + +fn supported_thinking_effort_levels(model: &AvailableModel) -> Vec { + let Some(default_effort) = default_thinking_reasoning_effort(model) else { + return Vec::new(); + }; + + open_ai::ReasoningEffort::OPENAI_COMPATIBLE_SELECTABLE + .into_iter() + .map(|effort| LanguageModelEffortLevel { + name: effort.label().into(), + value: effort.value().into(), + is_default: effort == default_effort, + }) + .collect() +} + +fn selected_thinking_reasoning_effort( + request: &LanguageModelRequest, +) -> Option { + request + .thinking_effort + .as_deref() + .and_then(|effort| effort.parse::().ok()) + .filter(|effort| *effort != open_ai::ReasoningEffort::None) +} + +fn chat_completion_max_tokens_parameter( + model: &AvailableModel, +) -> crate::provider::open_ai::ChatCompletionMaxTokensParameter { + if model.capabilities.max_tokens_parameter { + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxTokens + } else { + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens + } +} + +fn supports_none_reasoning_effort(model: &AvailableModel) -> bool { + model.reasoning_effort.is_some() +} + +fn chat_completion_reasoning_effort( + request: &LanguageModelRequest, + model: &AvailableModel, +) -> Option { + if model.reasoning_effort == Some(open_ai::ReasoningEffort::None) { + return Some(open_ai::ReasoningEffort::None); + } + + if request.thinking_allowed { + selected_thinking_reasoning_effort(request) + .or_else(|| default_thinking_reasoning_effort(model)) + } else if supports_none_reasoning_effort(model) { + Some(open_ai::ReasoningEffort::None) + } else { + None + } +} + +fn disable_response_thinking_for_none_effort( + request: &mut LanguageModelRequest, + model: &AvailableModel, +) { + if model.reasoning_effort == Some(open_ai::ReasoningEffort::None) { + request.thinking_allowed = false; + request.thinking_effort = None; + } +} + impl LanguageModel for OpenAiCompatibleLanguageModel { fn id(&self) -> LanguageModelId { self.id.clone() @@ -304,6 +378,14 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { true } + fn supports_thinking(&self) -> bool { + default_thinking_reasoning_effort(&self.model).is_some() + } + + fn supported_effort_levels(&self) -> Vec { + supported_thinking_effort_levels(&self.model) + } + fn supports_split_token_display(&self) -> bool { true } @@ -341,13 +423,15 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } if self.model.capabilities.chat_completions { + let reasoning_effort = chat_completion_reasoning_effort(&request, &self.model); let request = into_open_ai( request, &self.model.name, self.model.capabilities.parallel_tool_calls, self.model.capabilities.prompt_cache_key, self.max_output_tokens(), - self.model.reasoning_effort, + chat_completion_max_tokens_parameter(&self.model), + reasoning_effort, self.model.capabilities.interleaved_reasoning, ); let completions = self.stream_completion(request, cx); @@ -357,16 +441,15 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } .boxed() } else { + disable_response_thinking_for_none_effort(&mut request, &self.model); let request = into_open_ai_response( request, &self.model.name, self.model.capabilities.parallel_tool_calls, self.model.capabilities.prompt_cache_key, self.max_output_tokens(), - self.model - .reasoning_effort - .filter(|effort| *effort != open_ai::ReasoningEffort::None), - self.model.reasoning_effort == Some(open_ai::ReasoningEffort::None), + default_thinking_reasoning_effort(&self.model), + supports_none_reasoning_effort(&self.model), ); let completions = self.stream_response(request, cx); async move { @@ -377,3 +460,262 @@ impl LanguageModel for OpenAiCompatibleLanguageModel { } } } + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + + fn available_model(reasoning_effort: Option) -> AvailableModel { + AvailableModel { + name: "custom-model".to_string(), + display_name: None, + max_tokens: 128_000, + max_output_tokens: None, + max_completion_tokens: None, + reasoning_effort, + capabilities: ModelCapabilities { + chat_completions: false, + ..Default::default() + }, + } + } + + #[test] + fn configured_reasoning_effort_supports_thinking() { + assert_eq!( + default_thinking_reasoning_effort(&available_model(Some( + open_ai::ReasoningEffort::High + ))), + Some(open_ai::ReasoningEffort::High) + ); + } + + #[test] + fn missing_or_none_reasoning_effort_does_not_support_thinking() { + assert_eq!( + default_thinking_reasoning_effort(&available_model(None)), + None + ); + assert_eq!( + default_thinking_reasoning_effort(&available_model(Some( + open_ai::ReasoningEffort::None + ))), + None + ); + } + + #[test] + fn supported_thinking_effort_levels_use_configured_effort_as_default() { + let effort_levels = supported_thinking_effort_levels(&available_model(Some( + open_ai::ReasoningEffort::High, + ))); + let values = effort_levels + .iter() + .map(|level| level.value.as_ref()) + .collect::>(); + + assert_eq!(values, ["minimal", "low", "medium", "high", "xhigh"]); + assert_eq!( + effort_levels + .iter() + .find(|level| level.is_default) + .map(|level| level.value.as_ref()), + Some("high") + ); + } + + #[test] + fn supported_thinking_effort_levels_hide_missing_or_none_effort() { + assert!(supported_thinking_effort_levels(&available_model(None)).is_empty()); + assert!( + supported_thinking_effort_levels(&available_model(Some( + open_ai::ReasoningEffort::None + ))) + .is_empty() + ); + } + + #[test] + fn chat_completion_reasoning_effort_honors_request_and_configured_effort() { + let model = available_model(Some(open_ai::ReasoningEffort::Medium)); + let mut request = LanguageModelRequest { + thinking_allowed: true, + ..Default::default() + }; + + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::Medium) + ); + + request.thinking_effort = Some("high".to_string()); + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::High) + ); + + request.thinking_effort = Some("not-supported".to_string()); + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::Medium) + ); + + request.thinking_allowed = false; + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::None) + ); + } + + #[test] + fn chat_completion_reasoning_effort_omits_missing_effort() { + let model = available_model(None); + let request = LanguageModelRequest { + thinking_allowed: false, + ..Default::default() + }; + + assert_eq!(chat_completion_reasoning_effort(&request, &model), None); + } + + #[test] + fn chat_completion_reasoning_effort_preserves_explicit_none() { + let model = available_model(Some(open_ai::ReasoningEffort::None)); + let request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + + assert_eq!( + chat_completion_reasoning_effort(&request, &model), + Some(open_ai::ReasoningEffort::None) + ); + } + + #[test] + fn chat_completion_max_tokens_parameter_defaults_to_max_completion_tokens() { + let model = available_model(Some(open_ai::ReasoningEffort::Medium)); + + assert_eq!( + chat_completion_max_tokens_parameter(&model), + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens + ); + } + + #[test] + fn chat_completion_max_tokens_parameter_uses_max_tokens_when_configured() { + let mut model = available_model(Some(open_ai::ReasoningEffort::Medium)); + model.capabilities.max_tokens_parameter = true; + + assert_eq!( + chat_completion_max_tokens_parameter(&model), + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxTokens + ); + } + + #[test] + fn response_request_includes_reasoning_when_effort_is_configured() { + let model = available_model(Some(open_ai::ReasoningEffort::High)); + let request = LanguageModelRequest { + thinking_allowed: true, + ..Default::default() + }; + + let request = into_open_ai_response( + request, + &model.name, + model.capabilities.parallel_tool_calls, + model.capabilities.prompt_cache_key, + model.max_output_tokens, + default_thinking_reasoning_effort(&model), + supports_none_reasoning_effort(&model), + ); + let serialized = serde_json::to_value(request).unwrap(); + + assert_eq!( + serialized["reasoning"], + json!({ "effort": "high", "summary": "auto" }) + ); + assert_eq!( + serialized["include"], + json!(["reasoning.encrypted_content"]) + ); + } + + #[test] + fn response_request_omits_reasoning_when_effort_is_missing() { + let model = available_model(None); + let request = LanguageModelRequest { + thinking_allowed: true, + ..Default::default() + }; + + let request = into_open_ai_response( + request, + &model.name, + model.capabilities.parallel_tool_calls, + model.capabilities.prompt_cache_key, + model.max_output_tokens, + default_thinking_reasoning_effort(&model), + supports_none_reasoning_effort(&model), + ); + let serialized = serde_json::to_value(request).unwrap(); + + assert_eq!(serialized.get("reasoning"), None); + assert_eq!(serialized.get("include"), None); + } + + #[test] + fn chat_completion_request_includes_selected_reasoning_effort() { + let mut model = available_model(Some(open_ai::ReasoningEffort::Medium)); + model.capabilities.chat_completions = true; + let request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + let reasoning_effort = chat_completion_reasoning_effort(&request, &model); + + let request = into_open_ai( + request, + &model.name, + model.capabilities.parallel_tool_calls, + model.capabilities.prompt_cache_key, + model.max_output_tokens, + chat_completion_max_tokens_parameter(&model), + reasoning_effort, + model.capabilities.interleaved_reasoning, + ); + let serialized = serde_json::to_value(request).unwrap(); + + assert_eq!(serialized["reasoning_effort"], json!("high")); + } + + #[test] + fn configured_reasoning_effort_supports_none_reasoning_effort() { + assert!(supports_none_reasoning_effort(&available_model(Some( + open_ai::ReasoningEffort::Medium + )))); + assert!(supports_none_reasoning_effort(&available_model(Some( + open_ai::ReasoningEffort::None + )))); + assert!(!supports_none_reasoning_effort(&available_model(None))); + } + + #[test] + fn response_thinking_effort_preserves_explicit_none() { + let model = available_model(Some(open_ai::ReasoningEffort::None)); + let mut request = LanguageModelRequest { + thinking_allowed: true, + thinking_effort: Some("high".to_string()), + ..Default::default() + }; + + disable_response_thinking_for_none_effort(&mut request, &model); + assert!(!request.thinking_allowed); + assert_eq!(request.thinking_effort, None); + } +} diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index 7ee2d980ed..c2ccaad088 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -27,7 +27,8 @@ use util::ResultExt; use crate::provider::anthropic::{AnthropicEventMapper, into_anthropic}; use crate::provider::google::{GoogleEventMapper, into_google}; use crate::provider::open_ai::{ - OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, + ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + into_open_ai_response, }; fn normalize_reasoning_effort(effort: &str) -> Option { @@ -693,6 +694,7 @@ impl LanguageModel for OpenCodeLanguageModel { false, false, self.model.max_output_tokens(self.subscription), + ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, self.model.interleaved_reasoning(), ); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 21f0b7c766..d319538bc4 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -482,6 +482,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel { self.model.capabilities.parallel_tool_calls, self.model.capabilities.prompt_cache_key, self.max_output_tokens(), + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens, None, false, ); @@ -617,6 +618,7 @@ async fn list_models( prompt_cache_key, chat_completions: true, interleaved_reasoning: false, + max_tokens_parameter: false, }, }); } diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index f66e31aa83..0627874bcc 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -443,6 +443,7 @@ impl LanguageModel for XAiLanguageModel { self.model.supports_parallel_tool_calls(), self.model.supports_prompt_cache_key(), self.max_output_tokens(), + crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens, reasoning_effort, false, ); diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index f04e32c695..0920f41821 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -41,7 +41,8 @@ use thiserror::Error; use anthropic::completion::{AnthropicEventMapper, AnthropicPromptCacheMode, into_anthropic}; use google_ai::completion::{GoogleEventMapper, into_google}; use open_ai::completion::{ - OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, into_open_ai_response, + ChatCompletionMaxTokensParameter, OpenAiEventMapper, OpenAiResponseEventMapper, into_open_ai, + into_open_ai_response, }; const PROVIDER_ID: LanguageModelProviderId = ZED_CLOUD_PROVIDER_ID; @@ -588,6 +589,7 @@ impl LanguageModel for CloudLanguageModel) -> Option, + max_tokens_parameter: ChatCompletionMaxTokensParameter, reasoning_effort: Option, interleaved_reasoning: bool, ) -> crate::Request { @@ -156,7 +163,14 @@ pub fn into_open_ai( }, stop: request.stop, temperature: request.temperature.or(Some(1.0)), - max_completion_tokens: max_output_tokens, + max_completion_tokens: match max_tokens_parameter { + ChatCompletionMaxTokensParameter::MaxCompletionTokens => max_output_tokens, + ChatCompletionMaxTokensParameter::MaxTokens => None, + }, + max_tokens: match max_tokens_parameter { + ChatCompletionMaxTokensParameter::MaxCompletionTokens => None, + ChatCompletionMaxTokensParameter::MaxTokens => max_output_tokens, + }, parallel_tool_calls: if supports_parallel_tool_calls && !request.tools.is_empty() { Some(supports_parallel_tool_calls) } else { @@ -608,13 +622,11 @@ impl OpenAiEventMapper { }; if let Some(delta) = choice.delta.as_ref() { + if let Some(reasoning) = delta.reasoning.clone() { + push_thinking_event(reasoning, &mut events); + } if let Some(reasoning_content) = delta.reasoning_content.clone() { - if !reasoning_content.is_empty() { - events.push(Ok(LanguageModelCompletionEvent::Thinking { - text: reasoning_content, - signature: None, - })); - } + push_thinking_event(reasoning_content, &mut events); } if let Some(content) = delta.content.clone() { if !content.is_empty() { @@ -703,6 +715,18 @@ impl OpenAiEventMapper { } } +fn push_thinking_event( + text: String, + events: &mut Vec>, +) { + if !text.is_empty() { + events.push(Ok(LanguageModelCompletionEvent::Thinking { + text, + signature: None, + })); + } +} + #[derive(Default)] struct RawToolCall { id: String, @@ -1799,7 +1823,16 @@ mod tests { compact_at_tokens: None, }; - let chat = into_open_ai(request, "gpt-5.4", true, true, None, None, false); + let chat = into_open_ai( + request, + "gpt-5.4", + true, + true, + None, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + None, + false, + ); let serialized = serde_json::to_value(&chat)?; assert_eq!( @@ -1813,6 +1846,45 @@ mod tests { Ok(()) } + #[test] + fn into_open_ai_can_send_max_tokens_parameter() -> Result<()> { + let request = LanguageModelRequest { + thread_id: None, + prompt_id: None, + intent: None, + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text("Hello".into())], + cache: false, + reasoning_details: None, + }], + tools: Vec::new(), + tool_choice: None, + stop: Vec::new(), + temperature: None, + thinking_allowed: false, + thinking_effort: None, + speed: None, + compact_at_tokens: None, + }; + + let chat = into_open_ai( + request, + "compatible-model", + false, + false, + Some(4096), + ChatCompletionMaxTokensParameter::MaxTokens, + None, + false, + ); + + let serialized = serde_json::to_value(&chat)?; + assert_eq!(serialized.get("max_completion_tokens"), None); + assert_eq!(serialized["max_tokens"], json!(4096)); + Ok(()) + } + #[test] fn into_open_ai_response_sends_none_reasoning_when_thinking_is_disabled() -> Result<()> { let request = LanguageModelRequest { @@ -3160,7 +3232,16 @@ mod tests { compact_at_tokens: None, }; - let result = into_open_ai(request.clone(), "model", false, false, None, None, true); + let result = into_open_ai( + request.clone(), + "model", + false, + false, + None, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + None, + true, + ); assert_eq!( serde_json::to_value(&result).unwrap()["messages"], json!([ @@ -3175,7 +3256,16 @@ mod tests { ]) ); - let result = into_open_ai(request, "model", false, false, None, None, false); + let result = into_open_ai( + request, + "model", + false, + false, + None, + ChatCompletionMaxTokensParameter::MaxCompletionTokens, + None, + false, + ); assert_eq!( serde_json::to_value(&result).unwrap()["messages"], json!([ @@ -3193,6 +3283,32 @@ mod tests { ); } + #[test] + fn stream_maps_reasoning() { + let events = map_completion_events(vec![ResponseStreamEvent { + choices: vec![ChoiceDelta { + index: 0, + delta: Some(ResponseMessageDelta { + role: None, + content: None, + reasoning: Some("thinking".into()), + tool_calls: None, + reasoning_content: None, + }), + finish_reason: None, + }], + usage: None, + }]); + + assert_eq!( + events, + vec![LanguageModelCompletionEvent::Thinking { + text: "thinking".into(), + signature: None, + }] + ); + } + #[test] fn stream_maps_preserves_tool_id_and_name_across_empty_deltas() { // DashScope sends id="" and name="" in subsequent tool_calls delta @@ -3207,6 +3323,7 @@ mod tests { delta: Some(ResponseMessageDelta { role: None, content: None, + reasoning: None, tool_calls: Some(vec![ToolCallChunk { index: 0, id: Some("call_dashscope_test".into()), @@ -3228,6 +3345,7 @@ mod tests { delta: Some(ResponseMessageDelta { role: None, content: None, + reasoning: None, tool_calls: Some(vec![ToolCallChunk { index: 0, id: Some("".into()), @@ -3248,6 +3366,7 @@ mod tests { delta: Some(ResponseMessageDelta { role: None, content: None, + reasoning: None, tool_calls: Some(vec![ToolCallChunk { index: 0, id: Some("".into()), diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 0987af568b..e706e10a0e 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -494,6 +494,8 @@ pub struct Request { pub stream_options: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub max_completion_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub stop: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -667,6 +669,7 @@ pub struct Choice { pub struct ResponseMessageDelta { pub role: Option, pub content: Option, + pub reasoning: Option, #[serde(default, skip_serializing_if = "is_none_or_empty")] pub tool_calls: Option>, #[serde(default, skip_serializing_if = "is_none_or_empty")] diff --git a/crates/settings_content/src/language_model.rs b/crates/settings_content/src/language_model.rs index 0869930bb6..a673ecc7a8 100644 --- a/crates/settings_content/src/language_model.rs +++ b/crates/settings_content/src/language_model.rs @@ -379,6 +379,8 @@ pub struct OpenAiCompatibleModelCapabilities { pub chat_completions: bool, #[serde(default)] pub interleaved_reasoning: bool, + #[serde(default)] + pub max_tokens_parameter: bool, } impl Default for OpenAiCompatibleModelCapabilities { @@ -390,6 +392,7 @@ impl Default for OpenAiCompatibleModelCapabilities { prompt_cache_key: false, chat_completions: default_true(), interleaved_reasoning: false, + max_tokens_parameter: false, } } } diff --git a/docs/src/ai/use-api-access.md b/docs/src/ai/use-api-access.md index 26610fd43c..b7458d4f41 100644 --- a/docs/src/ai/use-api-access.md +++ b/docs/src/ai/use-api-access.md @@ -499,7 +499,73 @@ By default, OpenAI-compatible models inherit these capabilities: - `prompt_cache_key`: `false` - `chat_completions`: `true` - `interleaved_reasoning`: `false` +- `max_tokens_parameter`: `false` If a model only works with the Responses API, set `capabilities.chat_completions` to `false`. Zed will use the Responses endpoint for that model. +For reasoning models (e.g. GPT-5), set `reasoning_effort` to the non-`none` effort level your endpoint supports. This enables thinking in the agent panel and tells Zed which effort to send when thinking is enabled. The provider settings UI can configure this when adding an OpenAI-compatible provider. Zed sends OpenAI-style `reasoning_effort` on chat-completions requests. For endpoints that document a maximum effort named `"max"` but also accept or map `"xhigh"` (such as OpenRouter and Z.AI), configure `"xhigh"` in Zed. + +If the model requires the Responses API for reasoning state, set `capabilities.chat_completions` to `false`: + +```json [settings] +{ + "language_models": { + "openai_compatible": { + "my-provider": { + "api_url": "https://example.com/v1", + "available_models": [ + { + "name": "gpt-5", + "max_tokens": 272000, + "reasoning_effort": "high", + "capabilities": { + "tools": true, + "images": false, + "parallel_tool_calls": false, + "prompt_cache_key": false, + "chat_completions": false, + "interleaved_reasoning": false, + "max_tokens_parameter": false + } + } + ] + } + } + } +} +``` + +Valid settings values are `"none"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, and `"xhigh"`. Use `"none"` in `settings.json` when you need to force reasoning off for an endpoint; the provider setup UI exposes the non-`none` values for thinking-capable models. For chat-completions endpoints that should receive prior thinking back in a dedicated `reasoning_content` field, also set `capabilities.interleaved_reasoning` to `true`. If the endpoint expects the output-token limit as `max_tokens` instead of `max_completion_tokens`, set `capabilities.max_tokens_parameter` to `true`. + +For example, a chat-completions endpoint with the maximum OpenAI-style reasoning effort, streamed thinking, and `max_tokens` output limits can be configured as: + +```json [settings] +{ + "language_models": { + "openai_compatible": { + "my-reasoning-provider": { + "api_url": "https://example.com/v1", + "available_models": [ + { + "name": "reasoning-model", + "max_tokens": 1000000, + "max_output_tokens": 128000, + "reasoning_effort": "xhigh", + "capabilities": { + "tools": true, + "images": false, + "parallel_tool_calls": false, + "prompt_cache_key": false, + "chat_completions": true, + "interleaved_reasoning": true, + "max_tokens_parameter": true + } + } + ] + } + } + } +} +``` + Enter the API key in the provider settings UI or set the generated environment variable. Do not put API keys in `settings.json`.