diff --git a/crates/goose-provider-types/src/canonical/data/canonical_models.json b/crates/goose-provider-types/src/canonical/data/canonical_models.json index d12cd9e1ab55..8d0b30e18ba0 100644 --- a/crates/goose-provider-types/src/canonical/data/canonical_models.json +++ b/crates/goose-provider-types/src/canonical/data/canonical_models.json @@ -15565,6 +15565,105 @@ "output": 128000 } }, + { + "id": "amazon-bedrock/openai.gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "family": "gpt-luna", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 1.0, + "output": 6.0, + "cache_read": 0.1, + "cache_write": 1.25 + }, + "limit": { + "context": 272000, + "output": 128000 + } + }, + { + "id": "amazon-bedrock/openai.gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "family": "gpt-sol", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 5.0, + "output": 30.0, + "cache_read": 0.5, + "cache_write": 6.25 + }, + "limit": { + "context": 272000, + "output": 128000 + } + }, + { + "id": "amazon-bedrock/openai.gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "family": "gpt-terra", + "attachment": true, + "reasoning": true, + "tool_call": true, + "temperature": false, + "knowledge": "2026-02-16", + "release_date": "2026-07-09", + "last_updated": "2026-07-09", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "cost": { + "input": 2.5, + "output": 15.0, + "cache_read": 0.25, + "cache_write": 3.125 + }, + "limit": { + "context": 272000, + "output": 128000 + } + }, { "id": "amazon-bedrock/openai.gpt-oss-120b", "name": "gpt-oss-120b", diff --git a/crates/goose-provider-types/src/formats/databricks.rs b/crates/goose-provider-types/src/formats/databricks.rs index cbf0d2b9fc91..8e9d5c2d07c2 100644 --- a/crates/goose-provider-types/src/formats/databricks.rs +++ b/crates/goose-provider-types/src/formats/databricks.rs @@ -1158,7 +1158,7 @@ mod tests { } #[test] - fn test_create_request_off_effort_preserves_none() -> anyhow::Result<()> { + fn test_create_request_off_effort_uses_low() -> anyhow::Result<()> { let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), serde_json::json!("off")); let model_config = ModelConfig { @@ -1172,7 +1172,7 @@ mod tests { reasoning: None, }; let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?; - assert_eq!(request["reasoning_effort"], "none"); + assert_eq!(request["reasoning_effort"], "low"); assert!(request.get("thinking_effort").is_none()); Ok(()) } diff --git a/crates/goose-provider-types/src/formats/openai.rs b/crates/goose-provider-types/src/formats/openai.rs index a8f5793f1e08..5d46576e1495 100644 --- a/crates/goose-provider-types/src/formats/openai.rs +++ b/crates/goose-provider-types/src/formats/openai.rs @@ -1515,13 +1515,10 @@ pub fn openai_reasoning_effort_for_thinking( model_name: &str, effort: ThinkingEffort, ) -> Option { - if effort == ThinkingEffort::Off { - return Some("none".to_string()); - } - let supported = openai_reasoning_efforts_for_model(model_name); + let preferred: &[&str] = match effort { - ThinkingEffort::Off => unreachable!(), + ThinkingEffort::Off => &["none", "low"], ThinkingEffort::Low => &["low", "medium", "high", "xhigh"], ThinkingEffort::Medium => &["medium", "high", "low", "xhigh"], ThinkingEffort::High => &["high", "medium", "xhigh", "low"], @@ -1534,7 +1531,7 @@ pub fn openai_reasoning_effort_for_thinking( .map(|level| (*level).to_string()) } -fn openai_reasoning_efforts_for_model(model_name: &str) -> &'static [&'static str] { +pub(crate) fn openai_reasoning_efforts_for_model(model_name: &str) -> &'static [&'static str] { let normalized = model_name.to_ascii_lowercase(); if normalized.contains("gpt-5") { @@ -1547,7 +1544,7 @@ fn openai_reasoning_efforts_for_model(model_name: &str) -> &'static [&'static st || normalized.contains("gpt-5.6") || normalized.contains("gpt-5-6") { - &["low", "medium", "high", "xhigh"] + &["none", "low", "medium", "high", "xhigh"] } else { &["low", "medium", "high"] } @@ -2502,27 +2499,6 @@ mod tests { Ok(()) } - #[test] - fn test_create_request_o3_off_effort_preserves_none() -> anyhow::Result<()> { - let model_config = test_model_config("o3") - .with_max_tokens(Some(1024)) - .with_thinking_effort(ThinkingEffort::Off); - let request = create_request( - &model_config, - "system", - &[], - &[], - &ImageFormat::OpenAi, - false, - )?; - let obj = request.as_object().unwrap(); - - assert_eq!(obj.get("reasoning_effort"), Some(&json!("none"))); - assert!(obj.get("thinking_effort").is_none()); - - Ok(()) - } - #[test] fn test_create_request_gpt56_max_effort_uses_xhigh() -> anyhow::Result<()> { let model_config = test_model_config("gpt-5.6-luna") diff --git a/crates/goose-provider-types/src/formats/openai_responses.rs b/crates/goose-provider-types/src/formats/openai_responses.rs index 217dec6ecaac..f8e0aadd03b7 100644 --- a/crates/goose-provider-types/src/formats/openai_responses.rs +++ b/crates/goose-provider-types/src/formats/openai_responses.rs @@ -563,6 +563,14 @@ fn add_message_items(input_items: &mut Vec, messages: &[Message]) { } } +fn is_gpt_5_6_model(model_name: &str) -> bool { + let normalized = model_name.to_ascii_lowercase(); + normalized == "gpt-5.6" + || normalized.starts_with("gpt-5.6-") + || normalized == "gpt-5-6" + || normalized.starts_with("gpt-5-6-") +} + pub fn create_responses_request( model_config: &ModelConfig, system: &str, @@ -590,11 +598,15 @@ pub fn create_responses_request( let is_reasoning_model = is_openai_responses_model(&model_name); let reasoning_effort = if is_reasoning_model { if let Some(effort) = legacy_reasoning_effort.as_deref() { - effort - .parse() - .ok() - .and_then(|effort| openai_reasoning_effort_for_thinking(&model_name, effort)) - .or(legacy_reasoning_effort) + if effort.eq_ignore_ascii_case("none") { + legacy_reasoning_effort + } else { + effort + .parse() + .ok() + .and_then(|effort| openai_reasoning_effort_for_thinking(&model_name, effort)) + .or(legacy_reasoning_effort) + } } else { model_config .thinking_effort() @@ -605,20 +617,43 @@ pub fn create_responses_request( }; let store = model_config.request_param::("store").unwrap_or(false); + let reasoning_mode = model_config + .request_param::("reasoning_mode") + .map(|mode| { + let normalized = mode.to_ascii_lowercase(); + match normalized.as_str() { + "standard" | "pro" => Ok(normalized), + _ => Err(anyhow!( + "Invalid reasoning_mode '{}'. Supported values are: standard, pro", + mode + )), + } + }) + .transpose()?; + if reasoning_mode.is_some() && !is_gpt_5_6_model(&model_name) { + return Err(anyhow!( + "reasoning_mode is only supported for GPT-5.6 models" + )); + } let mut payload = json!({ "model": model_name, "input": input_items, "store": store, }); - if let Some(effort) = reasoning_effort { - payload.as_object_mut().unwrap().insert( - "reasoning".to_string(), - json!({ - "effort": effort, - "summary": "auto", - }), - ); + if reasoning_effort.is_some() || reasoning_mode.is_some() { + let mut reasoning = serde_json::Map::new(); + if let Some(effort) = reasoning_effort { + reasoning.insert("effort".to_string(), json!(effort)); + reasoning.insert("summary".to_string(), json!("auto")); + } + if let Some(mode) = reasoning_mode { + reasoning.insert("mode".to_string(), json!(mode)); + } + payload + .as_object_mut() + .unwrap() + .insert("reasoning".to_string(), Value::Object(reasoning)); } if !tools.is_empty() { @@ -650,10 +685,12 @@ pub fn create_responses_request( } } - payload.as_object_mut().unwrap().insert( - "max_output_tokens".to_string(), - json!(model_config.max_output_tokens()), - ); + if let Some(max_tokens) = model_config.max_tokens { + payload + .as_object_mut() + .unwrap() + .insert("max_output_tokens".to_string(), json!(max_tokens)); + } Ok(payload) } @@ -1455,6 +1492,46 @@ mod tests { assert_eq!(result["reasoning"]["summary"], "auto"); } + #[test] + fn test_responses_request_supports_gpt_5_6_xhigh_effort() { + let model_config = ModelConfig::new("gpt-5.6-sol-xhigh"); + + let result = create_responses_request(&model_config, "You are helpful.", &[], &[]).unwrap(); + + assert_eq!(result["model"], "gpt-5.6-sol"); + assert_eq!(result["reasoning"]["effort"], "xhigh"); + assert_eq!(result["reasoning"]["summary"], "auto"); + } + + #[test] + fn test_responses_request_supports_gpt_5_6_reasoning_mode() { + let model_config = ModelConfig::new("gpt-5.6-sol").with_merged_request_params( + std::collections::HashMap::from([("reasoning_mode".to_string(), json!("pro"))]), + ); + + let result = create_responses_request(&model_config, "You are helpful.", &[], &[]).unwrap(); + + assert_eq!(result["reasoning"]["mode"], "pro"); + assert!(result["reasoning"].get("effort").is_none()); + assert!(result["reasoning"].get("summary").is_none()); + } + + #[test] + fn test_responses_request_rejects_reasoning_mode_for_non_gpt_5_6_model() { + for model_name in ["gpt-5.5", "gpt-5.60"] { + let model_config = ModelConfig::new(model_name).with_merged_request_params( + std::collections::HashMap::from([("reasoning_mode".to_string(), json!("pro"))]), + ); + + let error = create_responses_request(&model_config, "You are helpful.", &[], &[]) + .expect_err("reasoning mode should be gated to GPT-5.6 models"); + + assert!(error + .to_string() + .contains("reasoning_mode is only supported for GPT-5.6 models")); + } + } + #[test] fn test_responses_request_without_effort_suffix_omits_reasoning() { for model_name in ["gpt-5.4", "o3", "gpt-5-nano"] { @@ -1480,6 +1557,31 @@ mod tests { } } + #[test] + fn test_responses_request_omits_default_max_output_tokens_for_unknown_model() { + let model_config = ModelConfig::new("gpt-5.6-sol"); + + let result = create_responses_request(&model_config, "You are helpful.", &[], &[]).unwrap(); + + assert_eq!(result["model"], "gpt-5.6-sol"); + assert!( + result.get("max_output_tokens").is_none(), + "unknown/new models should not receive Goose's fallback max_output_tokens" + ); + } + + #[test] + fn test_responses_request_includes_canonical_max_output_tokens() { + let model_config = ModelConfig::new("gpt-5.4").with_canonical_limits("openai"); + + let result = create_responses_request(&model_config, "You are helpful.", &[], &[]).unwrap(); + + assert_eq!( + result["max_output_tokens"], + model_config.max_tokens.unwrap() + ); + } + #[test] fn test_responses_request_non_reasoning_model_ignores_global_thinking_effort() { let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", Some("high"))]); diff --git a/crates/goose-provider-types/src/model.rs b/crates/goose-provider-types/src/model.rs index 341beaea9979..fc448db6563c 100644 --- a/crates/goose-provider-types/src/model.rs +++ b/crates/goose-provider-types/src/model.rs @@ -624,6 +624,20 @@ mod tests { let config = ModelConfig::new("gpt-5.4-xhigh").with_canonical_limits("openai"); assert_eq!(config.context_limit, Some(1_050_000)); + // "gpt-5.6-sol-xhigh" should resolve via "gpt-5.6-sol" + let config = ModelConfig::new("gpt-5.6-sol-xhigh").with_canonical_limits("openai"); + assert_eq!(config.context_limit, Some(1_050_000)); + assert_eq!(config.max_tokens, Some(128_000)); + assert_eq!(config.reasoning, Some(true)); + let canonical = crate::canonical::maybe_get_canonical_model("openai", "gpt-5.6-sol") + .expect("gpt-5.6-sol should have canonical metadata"); + assert_eq!(canonical.temperature, Some(false)); + + let config = ModelConfig::new("gpt-5.6-sol").with_canonical_limits("chatgpt_codex"); + assert_eq!(config.context_limit, Some(1_050_000)); + assert_eq!(config.max_tokens, Some(128_000)); + assert_eq!(config.reasoning, Some(true)); + // "gpt-5.4-nano-low" should resolve via "gpt-5.4-nano" let config = ModelConfig::new("gpt-5.4-nano-low").with_canonical_limits("openai"); assert_eq!(config.context_limit, Some(400_000)); diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs index 3c920ca144df..1e6c2bba7ba0 100644 --- a/crates/goose-providers/src/openai.rs +++ b/crates/goose-providers/src/openai.rs @@ -62,9 +62,10 @@ pub const OPEN_AI_KNOWN_MODELS: &[(&str, usize)] = &[ ("gpt-5.4-pro", 1_050_000), ("gpt-5.5", 1_050_000), ("gpt-5.5-pro", 1_050_000), - ("gpt-5.6-luna", 1_050_000), + ("gpt-5.6", 1_050_000), ("gpt-5.6-sol", 1_050_000), ("gpt-5.6-terra", 1_050_000), + ("gpt-5.6-luna", 1_050_000), ]; pub const OPEN_AI_DOC_URL: &str = "https://platform.openai.com/docs/models"; @@ -844,26 +845,6 @@ mod tests { use crate::api_client::AuthMethod; use serde_json::json; - #[test] - fn gpt_5_5_and_5_6_models_have_expected_context_limits() { - for model in [ - "gpt-5.5", - "gpt-5.5-pro", - "gpt-5.6-luna", - "gpt-5.6-sol", - "gpt-5.6-terra", - ] { - assert_eq!( - OPEN_AI_KNOWN_MODELS - .iter() - .find(|(name, _)| *name == model) - .map(|(_, limit)| *limit), - Some(1_050_000), - "unexpected context limit for {model}" - ); - } - } - fn make_provider(name: &str) -> OpenAiProvider { OpenAiProvider { api_client: ApiClient::new_with_tls( @@ -1023,6 +1004,8 @@ mod tests { for (model_name, base_path, expected) in [ ("gpt-5.4", "v1/chat/completions", true), ("gpt-5.4-xhigh", "v1/chat/completions", true), + ("gpt-5.6-sol", "v1/chat/completions", true), + ("gpt-5.6-terra-xhigh", "v1/chat/completions", true), ("gpt-5.2-pro-2025-12-11", "v1/chat/completions", true), ("gpt-4o", "v1/chat/completions", false), ("gpt-5.2-codex", "openai/v1/chat/completions", false), diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 255e1807078c..836489ed4cb6 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -52,6 +52,22 @@ pub struct ChatGptCodexModelAttrs { } pub const CHATGPT_CODEX_KNOWN_MODELS: &[ChatGptCodexModelAttrs] = &[ + ChatGptCodexModelAttrs { + name: "gpt-5.6-sol", + reasoning_levels: &["none", "low", "medium", "high", "xhigh"], + }, + ChatGptCodexModelAttrs { + name: "gpt-5.6-terra", + reasoning_levels: &["none", "low", "medium", "high", "xhigh"], + }, + ChatGptCodexModelAttrs { + name: "gpt-5.6-luna", + reasoning_levels: &["none", "low", "medium", "high", "xhigh"], + }, + ChatGptCodexModelAttrs { + name: "gpt-5.6", + reasoning_levels: &["none", "low", "medium", "high", "xhigh"], + }, ChatGptCodexModelAttrs { name: "gpt-5.5", reasoning_levels: &["low", "medium", "high", "xhigh"], @@ -223,7 +239,13 @@ fn reasoning_effort_for_config(model_config: &ModelConfig) -> Option { .map(|effort| { let valid_levels = reasoning_levels_for_model(&model_config.model_name); let preferred_levels: &[&str] = match effort { - ThinkingEffort::Off => return None, + ThinkingEffort::Off => { + return Some(if valid_levels.contains(&"none") { + "none".to_string() + } else { + "low".to_string() + }); + } ThinkingEffort::Low => &["low", "medium", "high", "xhigh"], ThinkingEffort::Medium => &["medium", "high", "low", "xhigh"], ThinkingEffort::High => &["high", "medium", "xhigh", "low"], @@ -1201,14 +1223,14 @@ mod tests { } #[test] - fn test_create_codex_request_off_omits_reasoning_for_codex_models() { + fn test_create_codex_request_off_sets_none_for_gpt_5_6_models() { let mut params = std::collections::HashMap::new(); params.insert("thinking_effort".to_string(), json!("off")); - let mut config = ModelConfig::new("gpt-5.2-codex"); + let mut config = ModelConfig::new("gpt-5.6-sol"); config.request_params = Some(params); let payload = create_codex_request(&config, "sys", &[], &[]).unwrap(); - assert!(payload.get("reasoning").is_none()); + assert_eq!(payload["reasoning"]["effort"], "none"); assert!(payload.get("reasoning_effort").is_none()); } @@ -1374,11 +1396,25 @@ mod tests { assert_eq!(claims.chatgpt_account_id.as_deref(), Some("account-1")); } + #[test_case("gpt-5.6-sol", &["none", "low", "medium", "high", "xhigh"]; "gpt 5.6 sol supports extended reasoning levels")] + #[test_case("gpt-5.6-terra", &["none", "low", "medium", "high", "xhigh"]; "gpt 5.6 terra supports extended reasoning levels")] + #[test_case("gpt-5.6-luna", &["none", "low", "medium", "high", "xhigh"]; "gpt 5.6 luna supports extended reasoning levels")] + #[test_case("gpt-5.6", &["none", "low", "medium", "high", "xhigh"]; "gpt 5.6 supports extended reasoning levels")] #[test_case("unknown-model", &["medium", "high"]; "unknown model gets default reasoning levels")] fn test_reasoning_levels_for_model(model: &str, expected: &[&str]) { assert_eq!(reasoning_levels_for_model(model), expected); } + #[test] + fn test_known_model_names_include_gpt_5_6_models() { + let names = known_model_names(); + + assert!(names.contains(&"gpt-5.6-sol")); + assert!(names.contains(&"gpt-5.6-terra")); + assert!(names.contains(&"gpt-5.6-luna")); + assert!(names.contains(&"gpt-5.6")); + } + #[test] fn test_instructions_passed_through() { let model = ModelConfig::new("gpt-5.4");