mirror of
https://github.com/block/goose.git
synced 2026-10-19 01:27:15 +00:00
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:
parent
a54d979a48
commit
2ef50543d7
16 changed files with 979 additions and 90 deletions
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
515
crates/goose-providers/tests/catalog_reasoning.rs
Normal file
515
crates/goose-providers/tests/catalog_reasoning.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue