fix(providers): separate catalog reasoning from native OpenAI conventions

Resolve compatible reasoning effort from the selected provider catalog without applying native OpenAI roles, token parameters, or temperature suppression. Preserve provider-specific rules and explicit request overrides.

Add request-capture regression coverage and a self-test phase for catalog reasoning. Refs #12785.
This commit is contained in:
Jack Amadeo 2026-10-09 12:19:58 -04:00
parent a54d979a48
commit 2ef50543d7
16 changed files with 979 additions and 90 deletions

View file

@ -75,6 +75,96 @@ pub struct OpenAiFormatOptions {
pub thinking_preservation_format: Option<ThinkingPreservationFormat>,
}
/// Separates endpoint-specific request conventions from catalog model capabilities.
#[derive(Debug, Clone, Copy)]
pub struct OpenAiRequestContext<'a> {
pub provider_name: &'a str,
pub catalog_provider_id: Option<&'a str>,
pub native_openai: bool,
}
impl OpenAiRequestContext<'_> {
pub(crate) fn uses_openai_reasoning_parameters(
self,
model_config: &ModelConfig,
model_name: &str,
) -> bool {
self.native_openai && model_config.openai_reasoning_for_model(model_name)
}
pub(crate) fn reasoning_effort(
self,
model_config: &ModelConfig,
model_name: &str,
legacy_effort: Option<String>,
) -> Option<String> {
if self.native_openai {
if model_config.openai_reasoning_for_model(model_name) {
return model_config
.thinking_effort()
.map_or(legacy_effort, |effort| {
openai_reasoning_effort_for_thinking(model_name, effort)
});
}
if supports_xai_reasoning_effort(model_name) {
return model_config
.thinking_effort()
.and_then(|effort| xai_reasoning_effort_for_thinking(model_name, effort));
}
return None;
}
if model_config.reasoning == Some(false) {
return None;
}
let effort = model_config.thinking_effort()?;
if matches!(self.provider_name, "xai" | "xai_oauth" | "x-ai") {
if supports_xai_reasoning_effort(model_name) {
return xai_reasoning_effort_for_thinking(model_name, effort);
}
return None;
}
// A publisher's capability does not establish support on a compatible endpoint.
let registry = crate::canonical::CanonicalModelRegistry::bundled().ok()?;
let model = registry.get(
crate::canonical::map_provider_name(
self.catalog_provider_id.unwrap_or(self.provider_name),
),
model_name,
)?;
if model.reasoning == Some(false) {
return None;
}
let supported = model.reasoning_efforts.as_deref()?;
reasoning_effort_for_supported_levels(effort, supported).or_else(|| {
let fallback: &[&str] = match effort {
ThinkingEffort::Off | ThinkingEffort::Low => {
&["none", "minimal", "low", "medium", "high", "xhigh", "max"]
}
ThinkingEffort::Medium | ThinkingEffort::High | ThinkingEffort::Max => {
&["max", "xhigh", "high", "medium", "low", "minimal", "none"]
}
};
fallback
.iter()
.find(|level| supported.iter().any(|value| value == **level))
.map(|level| (*level).to_string())
})
}
pub(crate) fn suppresses_temperature(
self,
model_config: &ModelConfig,
model_name: &str,
) -> bool {
self.uses_openai_reasoning_parameters(model_config, model_name)
|| (self.native_openai && supports_xai_reasoning_effort(model_name))
|| (matches!(self.provider_name, "xai" | "xai_oauth" | "x-ai")
&& supports_xai_reasoning_effort(model_name))
}
}
fn merge_reasoning_text(prefix: &str, suffix: &str) -> String {
if prefix.is_empty() {
return suffix.to_string();
@ -1740,6 +1830,32 @@ pub fn create_request_with_options(
)
}
#[allow(clippy::too_many_arguments)]
pub fn create_request_with_context(
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
image_format: &ImageFormat,
for_streaming: bool,
format_options: OpenAiFormatOptions,
context: OpenAiRequestContext<'_>,
) -> anyhow::Result<Value, Error> {
let (wire_model_name, _) = extract_reasoning_effort(&model_config.model_name);
create_request_for_model_with_context(
model_config,
&wire_model_name,
&model_config.model_name,
system,
messages,
tools,
image_format,
for_streaming,
format_options,
context,
)
}
#[allow(clippy::too_many_arguments)]
pub fn create_request_for_model_with_options(
model_config: &ModelConfig,
@ -1751,23 +1867,42 @@ pub fn create_request_for_model_with_options(
image_format: &ImageFormat,
for_streaming: bool,
format_options: OpenAiFormatOptions,
) -> anyhow::Result<Value, Error> {
create_request_for_model_with_context(
model_config,
wire_model_name,
capability_model_name,
system,
messages,
tools,
image_format,
for_streaming,
format_options,
OpenAiRequestContext {
provider_name: "openai",
catalog_provider_id: None,
native_openai: true,
},
)
}
#[allow(clippy::too_many_arguments)]
pub fn create_request_for_model_with_context(
model_config: &ModelConfig,
wire_model_name: &str,
capability_model_name: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
image_format: &ImageFormat,
for_streaming: bool,
format_options: OpenAiFormatOptions,
context: OpenAiRequestContext<'_>,
) -> anyhow::Result<Value, Error> {
let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(capability_model_name);
let is_reasoning_model = model_config.openai_reasoning_for_model(&model_name);
let supports_xai_effort = supports_xai_reasoning_effort(&model_name);
let reasoning_effort = if is_reasoning_model {
model_config
.thinking_effort()
.map_or(legacy_reasoning_effort, |effort| {
openai_reasoning_effort_for_thinking(&model_name, effort)
})
} else if supports_xai_effort {
model_config
.thinking_effort()
.and_then(|effort| xai_reasoning_effort_for_thinking(&model_name, effort))
} else {
None
};
let is_reasoning_model = context.uses_openai_reasoning_parameters(model_config, &model_name);
let reasoning_effort =
context.reasoning_effort(model_config, &model_name, legacy_reasoning_effort);
let system_message = serde_json::to_value(OpenAiMessage {
role: if is_reasoning_model {
@ -1801,7 +1936,7 @@ pub fn create_request_for_model_with_options(
payload["tools"] = json!(tools_spec);
}
if !is_reasoning_model && !supports_xai_effort {
if !context.suppresses_temperature(model_config, &model_name) {
if let Some(temp) = model_config.temperature {
payload["temperature"] = json!(temp);
}
@ -1940,6 +2075,19 @@ pub fn openai_reasoning_effort_for_thinking(
.as_ref()
.and_then(|model| model.reasoning_efforts.as_deref());
match catalog_efforts {
Some(supported) => reasoning_effort_for_supported_levels(effort, supported),
None => reasoning_effort_for_supported_levels(
effort,
openai_reasoning_efforts_for_model(model_name),
),
}
}
fn reasoning_effort_for_supported_levels(
effort: ThinkingEffort,
supported: &[impl AsRef<str>],
) -> Option<String> {
let preferred: &[&str] = match effort {
ThinkingEffort::Off => &["none", "minimal", "low"],
ThinkingEffort::Low => &["low", "minimal", "medium", "high", "xhigh"],
@ -1950,10 +2098,7 @@ pub fn openai_reasoning_effort_for_thinking(
preferred
.iter()
.find(|level| match catalog_efforts {
Some(values) => values.iter().any(|value| value == **level),
None => openai_reasoning_efforts_for_model(model_name).contains(level),
})
.find(|level| supported.iter().any(|value| value.as_ref() == **level))
.map(|level| (*level).to_string())
}

View file

@ -7,6 +7,7 @@ use crate::documents::{
use crate::errors::ProviderError;
use crate::formats::openai::{
extract_reasoning_effort, openai_reasoning_effort_for_thinking, sanitize_function_name,
OpenAiRequestContext,
};
use crate::maybe_send::MaybeSend;
use crate::mcp_utils::extract_text_from_resource;
@ -623,6 +624,25 @@ pub fn create_responses_request(
)
}
pub fn create_responses_request_with_context(
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
context: OpenAiRequestContext<'_>,
) -> anyhow::Result<Value, Error> {
let (wire_model_name, _) = extract_reasoning_effort(&model_config.model_name);
create_responses_request_for_model_with_context(
model_config,
&wire_model_name,
&model_config.model_name,
system,
messages,
tools,
context,
)
}
pub fn create_responses_request_for_model(
model_config: &ModelConfig,
wire_model_name: &str,
@ -630,6 +650,30 @@ pub fn create_responses_request_for_model(
system: &str,
messages: &[Message],
tools: &[Tool],
) -> anyhow::Result<Value, Error> {
create_responses_request_for_model_with_context(
model_config,
wire_model_name,
capability_model_name,
system,
messages,
tools,
OpenAiRequestContext {
provider_name: "openai",
catalog_provider_id: None,
native_openai: true,
},
)
}
pub fn create_responses_request_for_model_with_context(
model_config: &ModelConfig,
wire_model_name: &str,
capability_model_name: &str,
system: &str,
messages: &[Message],
tools: &[Tool],
context: OpenAiRequestContext<'_>,
) -> anyhow::Result<Value, Error> {
let mut input_items = Vec::new();
@ -651,11 +695,7 @@ pub fn create_responses_request_for_model(
);
let (model_name, legacy_reasoning_effort) = extract_reasoning_effort(capability_model_name);
// All models routed here are responses-capable; temperature is rejected
// by the API for reasoning models regardless of whether an explicit
// effort suffix was provided.
let is_reasoning_model = model_config.openai_reasoning_for_model(&model_name);
let reasoning_effort = if is_reasoning_model {
let reasoning_effort = if context.uses_openai_reasoning_parameters(model_config, &model_name) {
if let Some(effort) = legacy_reasoning_effort.as_deref() {
if effort.eq_ignore_ascii_case("none") {
legacy_reasoning_effort
@ -671,8 +711,10 @@ pub fn create_responses_request_for_model(
.thinking_effort()
.and_then(|effort| openai_reasoning_effort_for_thinking(&model_name, effort))
}
} else {
} else if context.native_openai {
None
} else {
context.reasoning_effort(model_config, &model_name, legacy_reasoning_effort)
};
let reasoning_effort = model_config
.request_param::<String>("reasoning_effort")
@ -739,7 +781,7 @@ pub fn create_responses_request_for_model(
.insert("tools".to_string(), json!(tools_spec));
}
if !is_reasoning_model {
if !context.suppresses_temperature(model_config, &model_name) {
if let Some(temp) = model_config.temperature {
payload
.as_object_mut()

View file

@ -1144,7 +1144,7 @@ mod tests {
.body_json()
.unwrap();
assert_eq!(payload["model"], "production-chat");
assert!(payload.get("temperature").is_none());
assert_eq!(payload["temperature"], json!(0.7_f32));
assert!(payload.get("capability_model").is_none());
assert!(config.request_params.is_none());
assert!(serde_json::to_value(&config)

View file

@ -1,5 +1,5 @@
use crate::formats::openai::{
extract_reasoning_effort, is_openai_responses_model, openai_reasoning_effort_for_thinking,
extract_reasoning_effort, is_openai_responses_model, OpenAiRequestContext,
};
use crate::images::ImageFormat;
use anyhow::Result;
@ -21,7 +21,7 @@ use crate::databricks_auth::{
use crate::errors::ProviderError;
use crate::formats::databricks::create_request_for_provider;
pub use crate::formats::databricks::DATABRICKS_PROVIDER_NAME;
use crate::formats::openai_responses::create_responses_request;
use crate::formats::openai_responses::create_responses_request_with_context;
use crate::model::ModelConfig;
use crate::openai_compatible::{
handle_status, map_http_error_to_provider_error, sanitize_url, stream_openai_compat,
@ -627,33 +627,38 @@ impl Provider for DatabricksProvider {
let client_request_id = self.build_client_request_id(&session_id);
if is_responses_model {
let catalog_model_name = crate::canonical::CanonicalModelRegistry::bundled()
.ok()
.filter(|registry| {
registry
.get(DATABRICKS_PROVIDER_NAME, &endpoint_name)
.is_some()
})
.map(|_| endpoint_name.as_str())
.unwrap_or(effective_model_name);
let responses_model_config;
let request_model_config = if effective_model_name != model_config.model_name {
let request_model_config = if catalog_model_name != model_config.model_name {
responses_model_config = {
let mut config = model_config.clone();
config.model_name = effective_model_name.to_string();
config.model_name = catalog_model_name.to_string();
config
};
&responses_model_config
} else {
model_config
};
let mut payload =
create_responses_request(request_model_config, system, messages, tools)?;
let mut payload = create_responses_request_with_context(
request_model_config,
system,
messages,
tools,
OpenAiRequestContext {
provider_name: self.get_name(),
catalog_provider_id: None,
native_openai: false,
},
)?;
payload["model"] = Value::String(endpoint_name.clone());
if payload.get("reasoning").is_none() {
if let Some(effort) = model_config.thinking_effort().and_then(|effort| {
openai_reasoning_effort_for_thinking(effective_model_name, effort)
}) {
payload.as_object_mut().unwrap().insert(
"reasoning".to_string(),
json!({
"effort": effort,
"summary": "auto",
}),
);
}
}
payload["stream"] = Value::Bool(true);
if let Some(ref client_request_id) = client_request_id {
payload["client_request_id"] = Value::String(client_request_id.clone());

View file

@ -1,5 +1,8 @@
use crate::formats::anthropic::{AnthropicFormatOptions, ANTHROPIC_PROVIDER_NAME};
use crate::formats::openai::{self, extract_reasoning_effort, is_openai_responses_model};
use crate::formats::openai::{
self, extract_reasoning_effort, is_openai_responses_model, OpenAiFormatOptions,
OpenAiRequestContext,
};
use crate::http_status::{read_error_body, read_json_response};
use crate::images::ImageFormat;
use anyhow::{anyhow, Result};
@ -342,8 +345,17 @@ impl DatabricksV2Provider {
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let mut payload =
openai_responses::create_responses_request(model_config, system, messages, tools)?;
let mut payload = openai_responses::create_responses_request_with_context(
model_config,
system,
messages,
tools,
OpenAiRequestContext {
provider_name: DATABRICKS_V2_PROVIDER_NAME,
catalog_provider_id: None,
native_openai: false,
},
)?;
payload["stream"] = Value::Bool(true);
let mut log = start_log(model_config, &payload)?;
@ -379,13 +391,23 @@ impl DatabricksV2Provider {
// Keep UC namespace text out of OpenAI format heuristics.
format_config.model_name = "model-service".to_string();
}
let mut payload = openai::create_request(
let mut payload = openai::create_request_with_context(
&format_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
true,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: format_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: DATABRICKS_V2_PROVIDER_NAME,
catalog_provider_id: None,
native_openai: false,
},
)?;
if is_model_service {
payload["model"] = Value::String(model_config.model_name.clone());

View file

@ -11,11 +11,11 @@ use crate::declarative::{DeclarativeProviderConfig, KeyResolver};
use crate::errors::ProviderError;
use crate::formats::openai::is_openai_responses_model;
use crate::formats::openai::{
create_request_with_options, get_cost, get_usage, is_reserved_request_param_key,
record_response_metadata, response_to_message, OpenAiFormatOptions,
create_request_for_model_with_context, get_cost, get_usage, is_reserved_request_param_key,
record_response_metadata, response_to_message, OpenAiFormatOptions, OpenAiRequestContext,
};
use crate::formats::openai_responses::{
create_responses_request_for_model, get_responses_usage, responses_api_to_message,
create_responses_request_for_model_with_context, get_responses_usage, responses_api_to_message,
ResponsesApiResponse,
};
use crate::http_status::read_json_response;
@ -137,6 +137,7 @@ pub struct OpenAiProvider {
skip_canonical_filtering: bool,
preserve_thinking_context: bool,
native_openai: bool,
catalog_provider_id: Option<String>,
#[serde(skip)]
n_ctx_cache: Arc<Mutex<HashMap<String, CachedContextLimit>>>,
}
@ -159,6 +160,7 @@ pub struct OpenAiProviderBuilder {
skip_canonical_filtering: bool,
preserve_thinking_context: bool,
native_openai: bool,
catalog_provider_id: Option<String>,
}
impl OpenAiProviderBuilder {
@ -176,6 +178,7 @@ impl OpenAiProviderBuilder {
skip_canonical_filtering: false,
preserve_thinking_context: false,
native_openai: false,
catalog_provider_id: None,
}
}
@ -247,6 +250,11 @@ impl OpenAiProviderBuilder {
self
}
pub fn catalog_provider_id(mut self, catalog_provider_id: Option<String>) -> Self {
self.catalog_provider_id = catalog_provider_id;
self
}
pub fn native_openai(mut self, native_openai: bool) -> Self {
self.native_openai = native_openai;
self
@ -266,12 +274,21 @@ impl OpenAiProviderBuilder {
skip_canonical_filtering: self.skip_canonical_filtering,
preserve_thinking_context: self.preserve_thinking_context,
native_openai: self.native_openai,
catalog_provider_id: self.catalog_provider_id,
n_ctx_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
}
impl OpenAiProvider {
fn request_context(&self) -> OpenAiRequestContext<'_> {
OpenAiRequestContext {
provider_name: &self.name,
catalog_provider_id: self.catalog_provider_id.as_deref(),
native_openai: self.native_openai,
}
}
pub async fn stream_for_model(
&self,
model_config: &ModelConfig,
@ -291,13 +308,14 @@ impl OpenAiProvider {
request_config.reasoning =
Some(canonical.and_then(|model| model.reasoning).unwrap_or(true));
}
let mut payload = create_responses_request_for_model(
let mut payload = create_responses_request_for_model_with_context(
&request_config,
wire_model,
capability_model,
system,
messages,
tools,
self.request_context(),
)?;
payload["stream"] = serde_json::Value::Bool(self.supports_streaming);
self.stream_responses_payload(model_config, payload).await
@ -376,6 +394,7 @@ impl OpenAiProvider {
skip_canonical_filtering: false,
preserve_thinking_context: false,
native_openai: false,
catalog_provider_id: None,
n_ctx_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
@ -815,8 +834,12 @@ impl Provider for OpenAiProvider {
declared_model.and_then(|m| m.thinking_preservation_format);
let supports_vision = self.vision_support(model_config).unwrap_or_default();
let mut payload = create_request_with_options(
let (wire_model, _) =
crate::formats::openai::extract_reasoning_effort(&model_config.model_name);
let mut payload = create_request_for_model_with_context(
model_config,
&wire_model,
&model_config.model_name,
system,
messages,
tools,
@ -828,6 +851,7 @@ impl Provider for OpenAiProvider {
supports_vision,
thinking_preservation_format,
},
self.request_context(),
)?;
if let Some(params) = declared_model.and_then(|m| m.request_params.as_ref()) {
@ -980,6 +1004,7 @@ pub fn from_declarative_config(
.custom_headers(config.headers)
.supports_streaming(config.supports_streaming.unwrap_or(true))
.name(config.name.clone())
.catalog_provider_id(config.catalog_provider_id)
.custom_models(custom_models)
.dynamic_models(config.dynamic_models)
.skip_canonical_filtering(config.skip_canonical_filtering)
@ -1042,6 +1067,7 @@ mod tests {
skip_canonical_filtering: false,
preserve_thinking_context: false,
native_openai: false,
catalog_provider_id: None,
n_ctx_cache: Arc::new(Mutex::new(HashMap::new())),
}
}
@ -1594,6 +1620,7 @@ mod tests {
skip_canonical_filtering: false,
preserve_thinking_context: false,
native_openai: false,
catalog_provider_id: None,
n_ctx_cache: Arc::new(Mutex::new(HashMap::new())),
}
}

View file

@ -19,9 +19,9 @@ use super::retry::ProviderRetry;
use crate::conversation::message::Message;
use crate::errors::ProviderError;
use crate::formats::openai::{
create_request, create_request_for_model_with_options, get_cost, get_usage,
create_request_for_model_with_context, extract_reasoning_effort, get_cost, get_usage,
record_response_metadata, response_to_message, response_to_streaming_message,
OpenAiFormatOptions,
OpenAiFormatOptions, OpenAiRequestContext,
};
use crate::formats::openai_responses::responses_api_to_streaming_message;
use crate::model::ModelConfig;
@ -63,7 +63,7 @@ impl OpenAiCompatibleProvider {
tools: &[Tool],
for_streaming: bool,
) -> Result<Value, ProviderError> {
create_request_for_model_with_options(
create_request_for_model_with_context(
model_config,
wire_model,
capability_model,
@ -77,6 +77,11 @@ impl OpenAiCompatibleProvider {
supports_vision: model_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: &self.name,
catalog_provider_id: None,
native_openai: false,
},
)
.map_err(|e| ProviderError::RequestFailed(format!("Failed to create request: {}", e)))
}
@ -155,15 +160,16 @@ impl OpenAiCompatibleProvider {
tools: &[Tool],
for_streaming: bool,
) -> Result<Value, ProviderError> {
create_request(
let (wire_model, _) = extract_reasoning_effort(&model_config.model_name);
self.build_request_for_model(
model_config,
&wire_model,
&model_config.model_name,
system,
messages,
tools,
&ImageFormat::OpenAi,
for_streaming,
)
.map_err(|e| ProviderError::RequestFailed(format!("Failed to create request: {}", e)))
}
}

View file

@ -13,7 +13,9 @@ use crate::cache_semantics::{apply_chat_payload_breakpoints, CacheSemantics};
use crate::conversation::message::Message;
use crate::decision::{DecisionProvider, DecisionRequest, DecisionResponse};
use crate::errors::ProviderError;
use crate::formats::openai::create_request;
use crate::formats::openai::{
create_request_with_context, OpenAiFormatOptions, OpenAiRequestContext,
};
use crate::http_status::read_json_response;
use crate::model::ModelConfig;
use crate::openai_compatible::{handle_status, stream_openai_compat};
@ -538,13 +540,32 @@ impl Provider for OpenRouterProvider {
model_config
};
let mut payload = create_request(
model_config,
// Non-OpenAI Off uses OpenRouter's toggle and mandatory-reasoning retry, not an effort clamp.
let mut format_config = model_config.clone();
if !crate::formats::openai::is_openai_responses_model(&model_config.model_name)
&& model_config.thinking_effort() == Some(crate::thinking::ThinkingEffort::Off)
{
if let Some(params) = format_config.request_params.as_mut() {
params.remove("thinking_effort");
}
}
let mut payload = create_request_with_context(
&format_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
true,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: model_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: OPENROUTER_PROVIDER_NAME,
catalog_provider_id: None,
native_openai: false,
},
)?;
if !session_id.is_empty() {

View file

@ -0,0 +1,515 @@
use goose_providers::{
api_client::{ApiClient, AuthMethod},
base::{ModelInfo, Provider},
conversation::message::Message,
declarative::{DeclarativeProviderConfig, KeyResolver},
model::ModelConfig,
openai::{from_declarative_config, OpenAiProviderBuilder},
thinking::ThinkingEffort,
};
use rmcp::model::Tool;
use serde_json::{json, Value};
use std::{collections::HashMap, convert::Infallible, sync::Arc};
use wiremock::{
matchers::{method, path},
Mock, MockServer, ResponseTemplate,
};
const CATALOG_MODEL: &str = "deepseek-v4.1-flash";
const SYSTEM: &str = "Use the supplied tools when needed";
fn model(name: &str, reasoning: Option<bool>, effort: ThinkingEffort) -> ModelConfig {
let mut config = ModelConfig::new(name)
.with_temperature(Some(0.5))
.with_max_tokens(Some(1024))
.with_thinking_effort(effort);
config.reasoning = reasoning;
config
}
async fn capture_request(
server: &MockServer,
provider: &dyn Provider,
config: &ModelConfig,
endpoint: &str,
) -> Value {
let response = if endpoint.ends_with("responses") {
json!({
"id": "response-1", "object": "response", "created_at": 0,
"status": "completed", "model": config.model_name,
"output": [{"type": "message", "role": "assistant", "content": [
{"type": "output_text", "text": "Done"}
]}]
})
} else {
json!({
"id": "response-1", "model": config.model_name,
"choices": [{"message": {"role": "assistant", "content": "Done"},
"finish_reason": "stop"}]
})
};
Mock::given(method("POST"))
.and(path(format!("/{endpoint}")))
.respond_with(ResponseTemplate::new(200).set_body_json(response))
.expect(1)
.mount(server)
.await;
let tool = Tool::new(
"lookup",
"Look up a value",
Arc::new(
json!({"type": "object", "properties": {"key": {"type": "string"}},
"required": ["key"]})
.as_object()
.unwrap()
.clone(),
),
);
provider
.complete(
config,
SYSTEM,
&[Message::user().with_text("Look up the answer")],
&[tool],
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(requests.len(), 1);
let body: Value = requests[0].body_json().unwrap();
assert_eq!(body["model"], config.model_name);
assert_eq!(body["tools"][0]["type"], "function");
assert!(body.get("thinking_effort").is_none(), "{body}");
if endpoint.ends_with("responses") {
assert_eq!(body["tools"][0]["name"], "lookup");
assert_eq!(body["input"][0]["role"], "system");
assert_eq!(body["input"][0]["content"][0]["text"], SYSTEM);
assert_eq!(body["max_output_tokens"], 1024);
assert!(body.get("messages").is_none(), "{body}");
assert!(body.get("max_tokens").is_none(), "{body}");
assert!(body.get("max_completion_tokens").is_none(), "{body}");
assert!(body.get("reasoning_effort").is_none(), "{body}");
} else {
assert_eq!(body["tools"][0]["function"]["name"], "lookup");
assert_eq!(body["messages"][0]["content"], SYSTEM);
assert!(body.get("input").is_none(), "{body}");
assert!(body.get("max_output_tokens").is_none(), "{body}");
}
body
}
async fn request(
config: ModelConfig,
provider_name: &str,
catalog_provider_id: Option<&str>,
endpoint: &str,
native_openai: bool,
models: Vec<ModelInfo>,
) -> Value {
let server = MockServer::start().await;
let client = ApiClient::new_with_tls(server.uri(), AuthMethod::NoAuth, None)
.unwrap()
.with_loopback_http_only()
.unwrap();
let provider = OpenAiProviderBuilder::new(client)
.name(provider_name)
.catalog_provider_id(catalog_provider_id.map(str::to_string))
.base_path(endpoint)
.native_openai(native_openai)
.custom_models(Some(models))
.supports_streaming(false)
.build();
capture_request(&server, &provider, &config, endpoint).await
}
fn assert_compatible(body: &Value, endpoint: &str, effort: Option<&str>) {
assert_eq!(body["temperature"], 0.5, "{body}");
if endpoint.ends_with("responses") {
assert_eq!(
body.pointer("/reasoning/effort"),
effort.map(json).as_ref(),
"{body}"
);
if effort.is_none() {
assert!(body.get("reasoning").is_none(), "{body}");
}
} else {
assert_eq!(body["messages"][0]["role"], "system", "{body}");
assert_eq!(body["max_tokens"], 1024, "{body}");
assert!(body.get("max_completion_tokens").is_none(), "{body}");
assert_eq!(
body.get("reasoning_effort"),
effort.map(json).as_ref(),
"{body}"
);
}
}
fn json(value: &str) -> Value {
Value::String(value.to_string())
}
#[tokio::test]
async fn compatible_catalog_efforts_are_clamped_without_changing_protocol_fields() {
for endpoint in ["chat/completions", "v1/responses"] {
for reasoning in [None, Some(true), Some(false)] {
for (effort, expected) in [
(ThinkingEffort::Off, "low"),
(ThinkingEffort::Low, "low"),
(ThinkingEffort::Medium, "high"),
(ThinkingEffort::High, "high"),
(ThinkingEffort::Max, "max"),
] {
let body = request(
model(CATALOG_MODEL, reasoning, effort),
"ollama_cloud",
None,
endpoint,
false,
vec![],
)
.await;
assert_compatible(
&body,
endpoint,
(reasoning != Some(false)).then_some(expected),
);
}
}
}
}
#[tokio::test]
async fn selected_catalog_id_overrides_provider_name_and_accepts_provider_aliases() {
for endpoint in ["chat/completions", "v1/responses"] {
for (name, catalog_id, expected) in [
("custom_gateway", Some("ollama_cloud"), Some("max")),
("custom_gateway", Some("ollama-cloud"), Some("max")),
("ollama_cloud", Some("xai"), None),
("ollama_cloud", Some("unknown_catalog"), None),
("custom_gateway", None, None),
] {
let body = request(
model(CATALOG_MODEL, Some(true), ThinkingEffort::Max),
name,
catalog_id,
endpoint,
false,
vec![],
)
.await;
assert_compatible(&body, endpoint, expected);
}
}
}
#[tokio::test]
async fn compatible_models_do_not_borrow_reasoning_controls_from_upstream_publishers() {
for endpoint in ["chat/completions", "v1/responses"] {
for name in ["gpt-5.4", "o3-mini", "grok-4.5", "unknown-reasoner"] {
for reasoning in [None, Some(true), Some(false)] {
let config =
model(name, reasoning, ThinkingEffort::High).with_canonical_limits("openai");
let body = request(config, "custom_gateway", None, endpoint, false, vec![]).await;
assert_compatible(&body, endpoint, None);
}
}
let body = request(
model("gpt-5.4", Some(true), ThinkingEffort::High),
"ollama_cloud",
None,
endpoint,
false,
vec![],
)
.await;
assert_compatible(&body, endpoint, None);
}
}
#[tokio::test]
async fn catalog_models_without_effort_controls_and_case_mismatches_omit_generated_effort() {
for endpoint in ["chat/completions", "v1/responses"] {
for (provider, name) in [
("ollama_cloud", "kimi-k2.5"),
("ollama_cloud", "mistral-large-3:675b"),
("ollama_cloud", "DeepSeek-v4.1-flash"),
("ollama_cloud", "deepseek-v4.1-flash:custom"),
("xai", "grok-4.20-0309-reasoning"),
("xai", "grok-4.20-0309-non-reasoning"),
] {
let body = request(
model(name, Some(true), ThinkingEffort::Max),
provider,
None,
endpoint,
false,
vec![],
)
.await;
assert_compatible(&body, endpoint, None);
}
}
}
#[tokio::test]
async fn xai_preserves_effort_restrictions_and_temperature_handling() {
for endpoint in ["chat/completions", "v1/responses"] {
for (name, effort, expected) in [
("grok-4.3", ThinkingEffort::Off, "none"),
("grok-4.5", ThinkingEffort::Off, "low"),
("grok-4.5", ThinkingEffort::Medium, "medium"),
("grok-4.5", ThinkingEffort::Max, "high"),
("GROK-4.5", ThinkingEffort::Max, "high"),
] {
let body = request(
model(name, None, effort),
"xai",
None,
endpoint,
false,
vec![],
)
.await;
assert!(body.get("temperature").is_none(), "{body}");
let mut protocol_fields = body.clone();
protocol_fields["temperature"] = json!(0.5);
assert_compatible(&protocol_fields, endpoint, Some(expected));
}
}
}
#[tokio::test]
async fn raw_reasoning_effort_overrides_mapping_even_when_reasoning_is_disabled_or_unknown() {
for endpoint in ["chat/completions", "v1/responses"] {
for name in [CATALOG_MODEL, "unknown-reasoner"] {
for reasoning in [None, Some(true), Some(false)] {
let config =
model(name, reasoning, ThinkingEffort::Max).with_merged_request_params(
HashMap::from([("reasoning_effort".to_string(), json!("vendor-custom"))]),
);
let body = request(config, "ollama_cloud", None, endpoint, false, vec![]).await;
assert_compatible(&body, endpoint, Some("vendor-custom"));
}
}
}
}
#[tokio::test]
async fn declared_request_params_remain_final_chat_overrides() {
let mut info = ModelInfo::new(CATALOG_MODEL);
info.request_params = Some(HashMap::from([
("reasoning_effort".to_string(), json!("declared-effort")),
("temperature".to_string(), json!(0.25)),
("max_tokens".to_string(), json!(2048)),
("vendor_option".to_string(), json!(true)),
]));
for reasoning in [Some(true), Some(false)] {
let config =
model(CATALOG_MODEL, reasoning, ThinkingEffort::Max).with_merged_request_params(
HashMap::from([("reasoning_effort".to_string(), json!("raw-effort"))]),
);
let body = request(
config,
"ollama_cloud",
None,
"chat/completions",
false,
vec![info.clone()],
)
.await;
assert_eq!(body["messages"][0]["role"], "system");
assert_eq!(body["reasoning_effort"], "declared-effort");
assert_eq!(body["temperature"], 0.25);
assert_eq!(body["max_tokens"], 2048);
assert_eq!(body["vendor_option"], true);
assert!(body.get("max_completion_tokens").is_none(), "{body}");
}
}
#[tokio::test]
async fn declared_model_request_params_match_exact_case() {
let models: Vec<_> = [("Foo", "high"), ("foo", "low")]
.into_iter()
.map(|(name, effort)| {
let mut info = ModelInfo::new(name);
info.request_params = Some(HashMap::from([(
"reasoning_effort".to_string(),
json!(effort),
)]));
info
})
.collect();
for (name, expected) in [("Foo", Some("high")), ("foo", Some("low")), ("FOO", None)] {
let body = request(
model(name, Some(true), ThinkingEffort::Max),
"custom_gateway",
None,
"chat/completions",
false,
models.clone(),
)
.await;
assert_compatible(&body, "chat/completions", expected);
}
}
#[tokio::test]
async fn compatible_gpt_models_use_selected_catalog_without_native_protocol_rewrites() {
for endpoint in ["chat/completions", "v1/responses"] {
for reasoning in [None, Some(true), Some(false)] {
for (effort, expected) in [
(ThinkingEffort::Off, "none"),
(ThinkingEffort::Max, "xhigh"),
] {
let body = request(
model("gpt-5.4", reasoning, effort),
"custom_gateway",
Some("openai"),
endpoint,
false,
vec![],
)
.await;
assert_compatible(
&body,
endpoint,
(reasoning != Some(false)).then_some(expected),
);
}
}
}
}
#[tokio::test]
async fn catalog_reasoning_does_not_generate_effort_without_an_explicit_selection() {
for endpoint in ["v1/chat/completions", "v1/responses"] {
for reasoning in [None, Some(true), Some(false)] {
let mut config = ModelConfig::new(CATALOG_MODEL)
.with_temperature(Some(0.5))
.with_max_tokens(Some(1024));
config.reasoning = reasoning;
let body = request(config, "ollama_cloud", None, endpoint, false, vec![]).await;
assert_compatible(&body, endpoint, None);
}
}
}
#[tokio::test]
async fn native_openai_retains_reasoning_role_token_and_temperature_behavior() {
for endpoint in ["chat/completions", "v1/responses"] {
for reasoning in [None, Some(true)] {
let body = request(
model("o3-mini", reasoning, ThinkingEffort::High),
"openai",
None,
endpoint,
true,
vec![],
)
.await;
assert!(body.get("temperature").is_none(), "{body}");
if endpoint.ends_with("responses") {
assert_eq!(body["reasoning"]["effort"], "high");
} else {
assert_eq!(body["messages"][0]["role"], "developer");
assert_eq!(body["max_completion_tokens"], 1024);
assert!(body.get("max_tokens").is_none(), "{body}");
assert_eq!(body["reasoning_effort"], "high");
}
}
for (name, reasoning) in [("gpt-4o", None), ("o3-mini", Some(false))] {
let body = request(
model(name, reasoning, ThinkingEffort::High),
"openai",
None,
endpoint,
true,
vec![],
)
.await;
assert_compatible(&body, endpoint, None);
}
}
}
struct NoKey;
impl KeyResolver for NoKey {
type Error = Infallible;
fn resolve_key(&self, _: &str) -> Result<String, Self::Error> {
panic!("Unauthenticated test provider must not resolve a key")
}
}
#[tokio::test]
async fn declarative_provider_passes_selected_catalog_id_to_request_generation() {
for endpoint in ["chat/completions", "v1/responses"] {
let server = MockServer::start().await;
let config: DeclarativeProviderConfig = serde_json::from_value(json!({
"name": "custom_gateway", "engine": "openai", "display_name": "Test Gateway",
"base_url": server.uri(), "base_path": endpoint, "models": [],
"requires_auth": false, "supports_streaming": false,
"catalog_provider_id": "ollama_cloud"
}))
.unwrap();
let provider = from_declarative_config(config, None, NoKey)
.unwrap()
.map_api_client(|client| client.with_loopback_http_only().unwrap())
.build();
let body = capture_request(
&server,
&provider,
&model(CATALOG_MODEL, Some(true), ThinkingEffort::Medium),
endpoint,
)
.await;
assert_compatible(&body, endpoint, Some("high"));
}
}
#[tokio::test]
async fn catalog_identity_does_not_change_provider_temperature_conventions() {
for endpoint in ["chat/completions", "v1/responses"] {
let body = request(
model("grok-4.5", Some(true), ThinkingEffort::Max),
"custom_gateway",
Some("xai"),
endpoint,
false,
vec![],
)
.await;
assert_compatible(&body, endpoint, Some("high"));
}
}
#[tokio::test]
async fn limited_catalog_efforts_never_discard_an_explicit_selection() {
for endpoint in ["chat/completions", "v1/responses"] {
for (provider, name, effort, expected) in [
(
"ollama_cloud",
"deepseek-v4-flash",
ThinkingEffort::Off,
"high",
),
("opencode_go", "kimi-k3", ThinkingEffort::Low, "max"),
("opencode_go", "kimi-k3", ThinkingEffort::Medium, "max"),
("opencode_go", "kimi-k3", ThinkingEffort::High, "max"),
] {
let body = request(
model(name, Some(true), effort),
provider,
None,
endpoint,
false,
vec![],
)
.await;
assert_compatible(&body, endpoint, Some(expected));
}
}
}

View file

@ -20,8 +20,8 @@ use base64::Engine;
use futures::future::BoxFuture;
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
use goose_providers::errors::ProviderError;
use goose_providers::formats::openai::extract_reasoning_effort;
use goose_providers::formats::openai_responses::create_responses_request;
use goose_providers::formats::openai::{extract_reasoning_effort, OpenAiRequestContext};
use goose_providers::formats::openai_responses::create_responses_request_for_model_with_context;
use goose_providers::model::ModelConfig;
use goose_providers::request_log::{start_log, LoggerHandleExt};
use reqwest::header::{HeaderName, HeaderValue, AUTHORIZATION};
@ -978,13 +978,19 @@ impl Provider for BedrockProvider {
if let Some(entry) = find_model_entry(&model_config.model_name) {
if entry.endpoint == BedrockEndpoint::MantleResponses {
let capability_model_name =
entry.name.strip_prefix("openai.").unwrap_or(entry.name);
let mut normalized_config = model_config.clone();
normalized_config.model_name = capability_model_name.to_string();
let mut payload =
create_responses_request(&normalized_config, system, messages, tools)?;
payload["model"] = Value::String(entry.wire_model_id.to_string());
let mut payload = create_responses_request_for_model_with_context(
model_config,
entry.wire_model_id,
entry.name,
system,
messages,
tools,
OpenAiRequestContext {
provider_name: "amazon-bedrock",
catalog_provider_id: None,
native_openai: false,
},
)?;
payload["stream"] = Value::Bool(true);
if entry.name.starts_with("google.gemma-4-") {
payload["parallel_tool_calls"] = Value::Bool(false);

View file

@ -33,8 +33,11 @@ use super::base::{
use super::openai_compatible::handle_response_openai_compat;
use super::retry::ProviderRetry;
use super::utils::get_model;
use goose_providers::formats::openai::{create_request, get_usage, response_to_message};
use goose_providers::formats::openai_responses::create_responses_request;
use goose_providers::formats::openai::{
create_request_with_context, get_usage, response_to_message, OpenAiFormatOptions,
OpenAiRequestContext,
};
use goose_providers::formats::openai_responses::create_responses_request_with_context;
use crate::config::{Config, ConfigError};
use crate::conversation::message::{Message, MessageContent};
@ -501,8 +504,18 @@ impl GithubCopilotProvider {
tools: &[Tool],
has_images: bool,
) -> Result<MessageStream, ProviderError> {
let mut payload = create_responses_request(model_config, system, messages, tools)
.map_err(|e| ProviderError::RequestFailed(e.to_string()))?;
let mut payload = create_responses_request_with_context(
model_config,
system,
messages,
tools,
OpenAiRequestContext {
provider_name: "github-copilot",
catalog_provider_id: None,
native_openai: false,
},
)
.map_err(|e| ProviderError::RequestFailed(e.to_string()))?;
payload["stream"] = serde_json::Value::Bool(true);
let mut log = start_log(model_config, &payload)?;
@ -544,13 +557,23 @@ impl GithubCopilotProvider {
.any(|prefix| model_config.model_name.starts_with(prefix));
if supports_streaming {
let payload = create_request(
let payload = create_request_with_context(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
true,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: model_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: "github-copilot",
catalog_provider_id: None,
native_openai: false,
},
)?;
let mut log = start_log(model_config, &payload)?;
@ -575,13 +598,23 @@ impl GithubCopilotProvider {
stream_openai_compat(response, log)
} else {
let payload = create_request(
let payload = create_request_with_context(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
false,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: model_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: "github-copilot",
catalog_provider_id: None,
native_openai: false,
},
)?;
let mut log = start_log(model_config, &payload)?;

View file

@ -4,6 +4,9 @@ use futures::future::BoxFuture;
use goose_providers::cache_semantics::apply_chat_payload_breakpoints;
use goose_providers::conversation::token_usage::ProviderUsage;
use goose_providers::errors::ProviderError;
use goose_providers::formats::openai::{
create_request_with_context, OpenAiFormatOptions, OpenAiRequestContext,
};
use goose_providers::images::ImageFormat;
use serde_json::Value;
use std::collections::HashMap;
@ -303,13 +306,23 @@ impl Provider for LiteLLMProvider {
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let mut payload = goose_providers::formats::openai::create_request(
let mut payload = create_request_with_context(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
self.supports_streaming,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: model_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: LITELLM_PROVIDER_NAME,
catalog_provider_id: None,
native_openai: false,
},
)?;
if !model_config.prompt_cache_disabled() && self.supports_cache_control(model_config).await

View file

@ -7,7 +7,9 @@ use anyhow::Result;
use async_trait::async_trait;
use futures::future::BoxFuture;
use goose_providers::errors::ProviderError;
use goose_providers::formats::openai::create_request;
use goose_providers::formats::openai::{
create_request_with_context, OpenAiFormatOptions, OpenAiRequestContext,
};
use goose_providers::images::ImageFormat;
use goose_providers::model::ModelConfig;
use goose_providers::request_log::{start_log, LoggerHandleExt};
@ -181,13 +183,23 @@ impl Provider for NanoGptProvider {
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let payload = create_request(
let payload = create_request_with_context(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
true,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: model_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: NANOGPT_PROVIDER_NAME,
catalog_provider_id: None,
native_openai: false,
},
)?;
let mut log = start_log(model_config, &payload)?;

View file

@ -13,7 +13,9 @@ use futures::future::BoxFuture;
use goose_providers::errors::ProviderError;
use goose_providers::images::ImageFormat;
use goose_providers::formats::openai::create_request;
use goose_providers::formats::openai::{
create_request_with_context, OpenAiFormatOptions, OpenAiRequestContext,
};
use goose_providers::model::ModelConfig;
use goose_providers::request_log::{start_log, LoggerHandleExt};
use rmcp::model::Tool;
@ -137,13 +139,23 @@ impl Provider for TetrateProvider {
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let payload = create_request(
let payload = create_request_with_context(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
true,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: model_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: TETRATE_PROVIDER_NAME,
catalog_provider_id: None,
native_openai: false,
},
)?;
let mut log = start_log(model_config, &payload)?;

View file

@ -32,8 +32,7 @@
#[cfg(feature = "local-inference")]
use super::local_inference::LOCAL_LLM_MODEL_CONFIG_KEY;
use super::ollama::OLLAMA_DEFAULT_PORT;
use super::ollama::OLLAMA_HOST;
use super::ollama::{OLLAMA_DEFAULT_PORT, OLLAMA_HOST, OLLAMA_PROVIDER_NAME};
use crate::conversation::message::{Message, MessageContent};
use crate::conversation::Conversation;
use crate::model_config::model_config_from_user_config;
@ -41,7 +40,9 @@ use crate::providers::base::DEFAULT_PROVIDER_TIMEOUT_SECS;
use anyhow::Result;
use futures::StreamExt;
use goose_providers::errors::ProviderError;
use goose_providers::formats::openai::create_request;
use goose_providers::formats::openai::{
create_request_with_context, OpenAiFormatOptions, OpenAiRequestContext,
};
use goose_providers::images::ImageFormat;
use reqwest::Client;
use rmcp::model::{object, CallToolRequestParams, ContentBlock, Tool};
@ -1014,13 +1015,23 @@ impl OllamaInterpreter {
let model_config = model_config_from_user_config("ollama", model)?;
let mut payload = create_request(
let mut payload = create_request_with_context(
&model_config,
system_prompt,
&messages,
&[], // No tools
&ImageFormat::OpenAi,
false,
OpenAiFormatOptions {
preserve_thinking_context: true,
supports_vision: model_config.supports_vision.unwrap_or_default(),
..Default::default()
},
OpenAiRequestContext {
provider_name: OLLAMA_PROVIDER_NAME,
catalog_provider_id: None,
native_openai: false,
},
)?;
payload["stream"] = json!(false); // needed for the /api/chat endpoint to work

View file

@ -11,6 +11,7 @@ activities:
- Validate Azure AI Foundry provider routing and deployment aliases
- Validate the configurable Databricks AI Gateway path across all inference routes
- Validate declared vision through persisted custom-provider configuration and outbound requests
- Validate catalog reasoning without native OpenAI request conventions on compatible providers
- Analyze code structure and parsing capabilities
- Test extension discovery and management
- Test load tool for knowledge injection and discovery
@ -28,7 +29,7 @@ parameters:
input_type: string
requirement: optional
default: "all"
description: "Which test phases to run: all, basic, extensions, delegation, declared-vision, reasoning, zai-coding-plan, acp-effort, compaction, advanced"
description: "Which test phases to run: all, basic, extensions, delegation, declared-vision, catalog-reasoning, reasoning, zai-coding-plan, acp-effort, compaction, advanced"
- key: test_depth
input_type: string
@ -363,6 +364,24 @@ prompt: |
Log results to: {{ workspace_dir }}/declared_vision.md
{% endif %}
{% if test_phases == "all" or "catalog-reasoning" in test_phases %}
## Catalog Reasoning and Compatible Request Conventions
When running from a goose source checkout with a Rust toolchain:
1. Run `cargo test -p goose-providers --test catalog_reasoning`.
2. Verify compatible Chat requests use system messages, max_tokens, and explicit
temperature even for catalog reasoning models. Effort selection must use the
selected provider's accepted levels, not a publisher inferred from the model name.
3. Verify compatible Responses retain max_output_tokens and explicit temperature,
while native OpenAI retains its reasoning-specific request conventions.
4. Verify raw effort overrides, explicit reasoning disablement, provider catalog IDs,
unknown models, and xAI's fixed and adjustable reasoning restrictions.
These tests use local HTTP mocks and require no provider credentials. If the source
checkout or toolchain is unavailable, mark this phase as SKIPPED.
Log results to: {{ workspace_dir }}/catalog_reasoning.md
{% endif %}
{% if test_phases == "all" or "vision" in test_phases %}
## 📷 PHASE 3B: Local Inference Vision Testing