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 <anant@zed.dev>
This commit is contained in:
1 parent
bf8dfae583
commit
6bd3535308
14 files changed
+799
-43
No files matched your search
@@ -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<InputField>,
|
||||
max_output_tokens: Entity<InputField>,
|
||||
max_tokens: Entity<InputField>,
|
||||
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<Self>) -> impl IntoElement {
|
||||
fn render_model_section(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>) -> impl IntoElement + use<> {
|
||||
fn render_open_ai_reasoning_settings(
|
||||
&self,
|
||||
ix: usize,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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<Self>,
|
||||
) -> 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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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<open_ai::
|
||||
|
||||
model
|
||||
.reasoning_effort()
|
||||
.filter(|effort| *effort != ReasoningEffort::None)
|
||||
.filter(|effort| open_ai_reasoning_effort_is_supported(*effort))
|
||||
.or_else(|| {
|
||||
let supported_efforts = model.supported_reasoning_efforts();
|
||||
if supported_efforts.contains(&ReasoningEffort::Medium) {
|
||||
@@ -267,17 +268,39 @@ fn default_thinking_reasoning_effort(model: &open_ai::Model) -> Option<open_ai::
|
||||
supported_efforts
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|effort| *effort != ReasoningEffort::None)
|
||||
.find(|effort| open_ai_reasoning_effort_is_supported(*effort))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn open_ai_reasoning_effort_is_supported(effort: open_ai::ReasoningEffort) -> 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::<open_ai::ReasoningEffort>().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<LanguageModelEffortLevel> {
|
||||
@@ -291,18 +314,13 @@ fn supported_thinking_effort_levels(model: &open_ai::Model) -> Vec<LanguageModel
|
||||
.iter()
|
||||
.copied()
|
||||
.filter_map(|effort| {
|
||||
let (name, value) = match effort {
|
||||
open_ai::ReasoningEffort::None => 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,
|
||||
);
|
||||
|
||||
@@ -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<open_ai::ReasoningEffort> {
|
||||
model
|
||||
.reasoning_effort
|
||||
.filter(|effort| *effort != open_ai::ReasoningEffort::None)
|
||||
}
|
||||
|
||||
fn supported_thinking_effort_levels(model: &AvailableModel) -> Vec<LanguageModelEffortLevel> {
|
||||
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<open_ai::ReasoningEffort> {
|
||||
request
|
||||
.thinking_effort
|
||||
.as_deref()
|
||||
.and_then(|effort| effort.parse::<open_ai::ReasoningEffort>().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<open_ai::ReasoningEffort> {
|
||||
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<LanguageModelEffortLevel> {
|
||||
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<open_ai::ReasoningEffort>) -> 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::<Vec<_>>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ReasoningEffort> {
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
|
||||
self.model.supports_parallel_tool_calls,
|
||||
false,
|
||||
None,
|
||||
ChatCompletionMaxTokensParameter::MaxCompletionTokens,
|
||||
None,
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -37,12 +37,19 @@ fn service_tier_for(speed: Option<language_model_core::Speed>) -> Option<Service
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ChatCompletionMaxTokensParameter {
|
||||
MaxCompletionTokens,
|
||||
MaxTokens,
|
||||
}
|
||||
|
||||
pub fn into_open_ai(
|
||||
request: LanguageModelRequest,
|
||||
model_id: &str,
|
||||
supports_parallel_tool_calls: bool,
|
||||
supports_prompt_cache_key: bool,
|
||||
max_output_tokens: Option<u64>,
|
||||
max_tokens_parameter: ChatCompletionMaxTokensParameter,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
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<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>>,
|
||||
) {
|
||||
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()),
|
||||
|
||||
@@ -494,6 +494,8 @@ pub struct Request {
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_completion_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -667,6 +669,7 @@ pub struct Choice {
|
||||
pub struct ResponseMessageDelta {
|
||||
pub role: Option<Role>,
|
||||
pub content: Option<String>,
|
||||
pub reasoning: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "is_none_or_empty")]
|
||||
pub tool_calls: Option<Vec<ToolCallChunk>>,
|
||||
#[serde(default, skip_serializing_if = "is_none_or_empty")]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
Reference in new issue
Block a user