Add canonical thinking modes (#9743)

Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
jh-block
2026-06-11 17:59:58 +02:00
committed by GitHub
parent c6a3b5b4e1
commit 86e2f04264
9 changed files with 2037 additions and 1111 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ mod model;
mod name_builder;
mod registry;
pub use model::{CanonicalModel, Limit, Modalities, Modality, Pricing};
pub use model::{CanonicalModel, Limit, Modalities, Modality, Pricing, ThinkingMode};
pub use name_builder::{
canonical_name, map_provider_name, map_to_canonical_model, strip_version_suffix,
};
File diff suppressed because it is too large Load Diff
@@ -229,7 +229,7 @@
"env": [
"CORTECS_API_KEY"
],
"model_count": 50
"model_count": 51
},
{
"id": "crof",
@@ -318,7 +318,7 @@
"env": [
"FASTROUTER_API_KEY"
],
"model_count": 15
"model_count": 47
},
{
"id": "firepass",
@@ -351,7 +351,7 @@
"env": [
"FREEMODEL_API_KEY"
],
"model_count": 9
"model_count": 10
},
{
"id": "friendli",
@@ -551,17 +551,6 @@
],
"model_count": 4
},
{
"id": "meta-llama",
"display_name": "Llama",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.llama.com/compat/v1/",
"doc": "https://llama.developer.meta.com/docs/models",
"env": [
"LLAMA_API_KEY"
],
"model_count": 7
},
{
"id": "llmgateway",
"display_name": "LLM Gateway",
@@ -573,6 +562,17 @@
],
"model_count": 191
},
{
"id": "llmtr",
"display_name": "LLMTR",
"npm": "@ai-sdk/openai-compatible",
"api": "https://llmtr.com/v1",
"doc": "https://llmtr.com/docs",
"env": [
"LLMTR_API_KEY"
],
"model_count": 6
},
{
"id": "lmstudio",
"display_name": "LMStudio",
@@ -606,6 +606,17 @@
],
"model_count": 19
},
{
"id": "meta-llama",
"display_name": "Llama",
"npm": "@ai-sdk/openai-compatible",
"api": "https://api.llama.com/compat/v1/",
"doc": "https://llama.developer.meta.com/docs/models",
"env": [
"LLAMA_API_KEY"
],
"model_count": 7
},
{
"id": "minimax",
"display_name": "MiniMax (minimax.io)",
@@ -791,7 +802,7 @@
"env": [
"NVIDIA_API_KEY"
],
"model_count": 94
"model_count": 83
},
{
"id": "ollama-cloud",
@@ -813,7 +824,7 @@
"env": [
"OPENCODE_API_KEY"
],
"model_count": 69
"model_count": 70
},
{
"id": "opencode-go",
@@ -1167,7 +1178,7 @@
"env": [
"XIAOMI_API_KEY"
],
"model_count": 5
"model_count": 6
},
{
"id": "xiaomi-token-plan-ams",
@@ -1244,7 +1255,7 @@
"env": [
"ZENMUX_API_KEY"
],
"model_count": 107
"model_count": 108
},
{
"id": "zhipuai",
@@ -62,6 +62,14 @@ pub struct Limit {
pub output: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ThinkingMode {
Enabled,
Adaptive,
AlwaysOnAdaptive,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalModel {
/// Model identifier (e.g., "anthropic/claude-3-5-sonnet")
@@ -82,6 +90,10 @@ pub struct CanonicalModel {
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<bool>,
/// Request shape to use when enabling thinking/reasoning.
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking_mode: Option<ThinkingMode>,
/// Whether the model supports tool calling
#[serde(default)]
pub tool_call: bool,
+23 -1
View File
@@ -12,7 +12,7 @@ use clap::Parser;
use goose::providers::create_with_named_model;
use goose_providers::canonical::{
canonical_name, CanonicalModel, CanonicalModelRegistry, Limit, Modalities, Modality,
ModelMapping, Pricing,
ModelMapping, Pricing, ThinkingMode,
};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -346,6 +346,25 @@ fn get_string(value: &Value, field: &str) -> Option<String> {
value.get(field).and_then(|v| v.as_str()).map(String::from)
}
fn get_thinking_mode(canonical_id: &str, value: &Value) -> Option<ThinkingMode> {
value
.get("thinking_mode")
.and_then(|v| v.as_str())
.and_then(|mode| serde_json::from_value(Value::String(mode.to_string())).ok())
.or_else(|| inferred_thinking_mode(canonical_id))
}
fn inferred_thinking_mode(canonical_id: &str) -> Option<ThinkingMode> {
match canonical_id {
"anthropic/claude-fable-5" => Some(ThinkingMode::AlwaysOnAdaptive),
"anthropic/claude-opus-4.6" => Some(ThinkingMode::Adaptive),
"anthropic/claude-opus-4.7" => Some(ThinkingMode::Adaptive),
"anthropic/claude-opus-4.8" => Some(ThinkingMode::Adaptive),
"anthropic/claude-sonnet-4.6" => Some(ThinkingMode::Adaptive),
_ => None,
}
}
fn parse_modalities(model_data: &Value, field: &str) -> Vec<Modality> {
model_data
.get("modalities")
@@ -412,6 +431,7 @@ fn process_model(
family: get_string(model_data, "family"),
attachment: model_data.get("attachment").and_then(|v| v.as_bool()),
reasoning: model_data.get("reasoning").and_then(|v| v.as_bool()),
thinking_mode: get_thinking_mode(&canonical_id, model_data),
tool_call: model_data
.get("tool_call")
.and_then(|v| v.as_bool())
@@ -497,6 +517,7 @@ fn collect_provider_metadata(
println!(" Added {} ({}) - {} models", provider_id, npm, model_count);
}
metadata_list.sort_by(|a, b| a.id.cmp(&b.id));
metadata_list
}
@@ -722,6 +743,7 @@ mod tests {
family: None,
attachment: None,
reasoning: None,
thinking_mode: None,
tool_call: false,
temperature: None,
knowledge: None,
+4 -4
View File
@@ -12,8 +12,8 @@ use tokio_util::io::StreamReader;
use super::api_client::{ApiClient, AuthMethod};
use super::base::{ConfigKey, MessageStream, ModelInfo, Provider, ProviderDef, ProviderMetadata};
use super::formats::anthropic::{
create_request_with_options, response_to_streaming_message, thinking_type,
AnthropicFormatOptions, ThinkingType,
create_request_with_options_for_provider, response_to_streaming_message, thinking_type,
AnthropicFormatOptions, ThinkingType, ANTHROPIC_PROVIDER_NAME,
};
use super::inventory::{config_secret_value, serialize_string_map, InventoryIdentityInput};
use super::openai_compatible::handle_status;
@@ -26,7 +26,6 @@ use crate::providers::utils::RequestLog;
use futures::future::BoxFuture;
use rmcp::model::Tool;
const ANTHROPIC_PROVIDER_NAME: &str = "anthropic";
pub const ANTHROPIC_DEFAULT_MODEL: &str = "claude-sonnet-4-5";
const ANTHROPIC_DEFAULT_FAST_MODEL: &str = "claude-haiku-4-5";
const ANTHROPIC_KNOWN_MODELS: &[&str] = &[
@@ -341,7 +340,8 @@ impl Provider for AnthropicProvider {
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let mut payload = create_request_with_options(
let mut payload = create_request_with_options_for_provider(
ANTHROPIC_PROVIDER_NAME,
model_config,
system,
messages,
+3 -3
View File
@@ -18,7 +18,7 @@ use super::base::{
};
use super::databricks_auth::{DatabricksAuth, DatabricksAuthProvider};
use super::embedding::EmbeddingCapable;
use super::formats::databricks::create_request;
use super::formats::databricks::{create_request_for_provider, DATABRICKS_PROVIDER_NAME};
use super::formats::openai_responses::create_responses_request;
use super::openai_compatible::{
handle_response_openai_compat, handle_status, map_http_error_to_provider_error, sanitize_url,
@@ -58,7 +58,6 @@ struct CachedDatabricksEndpointInfo {
fetched_at: Instant,
}
const DATABRICKS_PROVIDER_NAME: &str = "databricks";
const DATABRICKS_ENDPOINT_METADATA_TTL_SECS: u64 = 60;
static DATABRICKS_ENDPOINT_INFO_CACHE: LazyLock<
Mutex<std::collections::HashMap<String, CachedDatabricksEndpointInfo>>,
@@ -670,7 +669,8 @@ impl Provider for DatabricksProvider {
model_config
};
let mut payload = create_request(
let mut payload = create_request_for_provider(
DATABRICKS_PROVIDER_NAME,
request_model_config,
system,
messages,
+121 -16
View File
@@ -1,7 +1,9 @@
use crate::conversation::message::{Message, MessageContent};
use crate::mcp_utils::extract_text_from_resource;
use crate::model::ModelConfig;
use crate::providers::canonical::maybe_get_canonical_model;
use anyhow::{anyhow, Result};
use goose_providers::canonical::ThinkingMode;
use goose_providers::conversation::token_usage::{ProviderUsage, Usage};
use goose_providers::errors::ProviderError;
use goose_providers::images::{convert_image, ImageFormat};
@@ -14,6 +16,8 @@ use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
pub(crate) const ANTHROPIC_PROVIDER_NAME: &str = "anthropic";
macro_rules! string_enum {
($name:ident { $($variant:ident => $str:literal),+ $(,)? }) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -68,27 +72,51 @@ impl AnthropicFormatOptions {
}
}
pub fn supports_adaptive_thinking(model_name: &str) -> bool {
let lower = model_name.to_lowercase();
lower.contains("claude-opus-4-6") || lower.contains("claude-sonnet-4-6")
fn canonical_thinking_mode(provider_name: &str, model_name: &str) -> Option<ThinkingMode> {
maybe_get_canonical_model(provider_name, model_name).and_then(|model| model.thinking_mode)
}
fn canonical_reasoning(provider_name: &str, model_config: &ModelConfig) -> Option<bool> {
maybe_get_canonical_model(provider_name, &model_config.model_name)
.and_then(|model| model.reasoning)
}
pub fn model_supports_temperature(provider_name: &str, model_config: &ModelConfig) -> bool {
maybe_get_canonical_model(provider_name, &model_config.model_name)
.and_then(|model| model.temperature)
.unwrap_or(true)
}
pub fn thinking_type(model_config: &ModelConfig) -> ThinkingType {
let model_lower = model_config.model_name.to_lowercase();
if !model_lower.contains("claude") {
thinking_type_for_provider(ANTHROPIC_PROVIDER_NAME, model_config)
}
pub fn thinking_type_for_provider(provider_name: &str, model_config: &ModelConfig) -> ThinkingType {
let mode = canonical_thinking_mode(provider_name, &model_config.model_name);
let reasoning = model_config
.reasoning
.or_else(|| canonical_reasoning(provider_name, model_config));
if reasoning != Some(true) {
return ThinkingType::Disabled;
}
let is_adaptive_model = supports_adaptive_thinking(&model_config.model_name);
if mode == Some(ThinkingMode::AlwaysOnAdaptive) {
return ThinkingType::Adaptive;
}
let effort = model_config.thinking_effort();
if effort.is_none() && legacy_thinking_budget_tokens().is_some() {
return ThinkingType::Enabled;
return match mode {
Some(ThinkingMode::Adaptive) => ThinkingType::Adaptive,
_ => ThinkingType::Enabled,
};
}
match effort.unwrap_or(ThinkingEffort::Off) {
ThinkingEffort::Off => ThinkingType::Disabled,
_ if is_adaptive_model => ThinkingType::Adaptive,
_ if mode == Some(ThinkingMode::Adaptive) => ThinkingType::Adaptive,
_ => ThinkingType::Enabled,
}
}
@@ -515,6 +543,13 @@ pub fn thinking_effort(model_config: &ModelConfig) -> ThinkingEffort {
.unwrap_or(ThinkingEffort::High)
}
pub fn adaptive_output_effort(model_config: &ModelConfig) -> ThinkingEffort {
match thinking_effort(model_config) {
ThinkingEffort::Off => ThinkingEffort::High,
effort => effort,
}
}
pub fn thinking_budget_tokens(model_config: &ModelConfig) -> i32 {
if let Some(request_param) = model_config
.request_params
@@ -553,15 +588,16 @@ fn legacy_thinking_budget_tokens() -> Option<i32> {
fn apply_thinking_config(
payload: &mut Value,
provider_name: &str,
model_config: &ModelConfig,
max_tokens: i32,
options: AnthropicFormatOptions,
) {
let obj = payload.as_object_mut().unwrap();
match thinking_type(model_config) {
match thinking_type_for_provider(provider_name, model_config) {
ThinkingType::Adaptive => {
obj.insert("thinking".to_string(), json!({"type": "adaptive"}));
let effort = thinking_effort(model_config).to_string();
let effort = adaptive_output_effort(model_config).to_string();
obj.insert("output_config".to_string(), json!({"effort": effort}));
}
ThinkingType::Enabled => {
@@ -620,6 +656,24 @@ pub fn create_request_with_options(
messages: &[Message],
tools: &[Tool],
options: AnthropicFormatOptions,
) -> Result<Value> {
create_request_with_options_for_provider(
ANTHROPIC_PROVIDER_NAME,
model_config,
system,
messages,
tools,
options,
)
}
pub fn create_request_with_options_for_provider(
provider_name: &str,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
options: AnthropicFormatOptions,
) -> Result<Value> {
let options = options.for_model(model_config);
let anthropic_messages = format_messages_with_options(messages, options);
@@ -651,14 +705,22 @@ pub fn create_request_with_options(
.insert("tools".to_string(), json!(tool_specs));
}
if let Some(temp) = model_config.temperature {
payload
.as_object_mut()
.unwrap()
.insert("temperature".to_string(), json!(temp));
if model_supports_temperature(provider_name, model_config) {
if let Some(temp) = model_config.temperature {
payload
.as_object_mut()
.unwrap()
.insert("temperature".to_string(), json!(temp));
}
}
apply_thinking_config(&mut payload, model_config, max_tokens, options);
apply_thinking_config(
&mut payload,
provider_name,
model_config,
max_tokens,
options,
);
Ok(payload)
}
@@ -1544,6 +1606,14 @@ mod tests {
thinking_type(&cfg_with_effort("claude-opus-4-6", "high")),
ThinkingType::Adaptive
);
assert_eq!(
thinking_type(&cfg_with_effort("claude-opus-4-7", "high")),
ThinkingType::Adaptive
);
assert_eq!(
thinking_type(&cfg_with_effort("claude-opus-4-8", "high")),
ThinkingType::Adaptive
);
// Adaptive model with off → disabled
assert_eq!(
thinking_type(&cfg_with_effort("claude-opus-4-6", "off")),
@@ -1561,6 +1631,41 @@ mod tests {
);
}
#[test]
fn test_thinking_type_always_on_adaptive() {
let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]);
assert_eq!(
thinking_type(&cfg("claude-fable-5")),
ThinkingType::Adaptive
);
assert_eq!(
thinking_type(&cfg_with_effort("claude-fable-5", "off")),
ThinkingType::Adaptive
);
assert_eq!(
thinking_type(&cfg_with_effort("claude-fable-5", "high")),
ThinkingType::Adaptive
);
}
#[test]
fn test_create_request_fable_5_omits_temperature() -> Result<()> {
let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]);
let mut config = cfg("claude-fable-5");
config.max_tokens = Some(4096);
config.temperature = Some(0.7);
let messages = vec![Message::user().with_text("Hello")];
let payload = create_request(&config, "system", &messages, &[])?;
assert_eq!(payload["thinking"]["type"], "adaptive");
assert!(payload.get("temperature").is_none());
assert_eq!(payload["output_config"]["effort"], "high");
Ok(())
}
#[test]
fn test_thinking_budget_uses_legacy_env() {
let _guard = env_lock::lock_env([
@@ -1,7 +1,8 @@
use crate::conversation::message::{Message, MessageContent};
use crate::model::ModelConfig;
use crate::providers::formats::anthropic::{
thinking_budget_tokens, thinking_effort, thinking_type, ThinkingType,
adaptive_output_effort, model_supports_temperature, thinking_budget_tokens,
thinking_type_for_provider, ThinkingType,
};
use anyhow::{anyhow, Error};
@@ -19,6 +20,8 @@ use serde::Serialize;
use serde_json::{json, Value};
use std::borrow::Cow;
pub(crate) const DATABRICKS_PROVIDER_NAME: &str = "databricks";
#[derive(Serialize)]
struct DatabricksMessage {
content: Value,
@@ -243,15 +246,19 @@ fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<Data
result
}
fn apply_claude_thinking_config(payload: &mut Value, model_config: &ModelConfig) {
fn apply_claude_thinking_config(
payload: &mut Value,
provider_name: &str,
model_config: &ModelConfig,
) {
let obj = payload.as_object_mut().unwrap();
match thinking_type(model_config) {
match thinking_type_for_provider(provider_name, model_config) {
ThinkingType::Adaptive => {
obj.insert("thinking".to_string(), json!({ "type": "adaptive" }));
obj.insert(
"output_config".to_string(),
json!({ "effort": thinking_effort(model_config).to_string() }),
json!({ "effort": adaptive_output_effort(model_config).to_string() }),
);
obj.insert(
"max_completion_tokens".to_string(),
@@ -272,8 +279,10 @@ fn apply_claude_thinking_config(payload: &mut Value, model_config: &ModelConfig)
obj.insert("temperature".to_string(), json!(2));
}
ThinkingType::Disabled => {
if let Some(temp) = model_config.temperature {
obj.insert("temperature".to_string(), json!(temp));
if model_supports_temperature(provider_name, model_config) {
if let Some(temp) = model_config.temperature {
obj.insert("temperature".to_string(), json!(temp));
}
}
obj.insert(
"max_completion_tokens".to_string(),
@@ -585,6 +594,24 @@ pub fn create_request(
messages: &[Message],
tools: &[Tool],
image_format: &ImageFormat,
) -> anyhow::Result<Value, Error> {
create_request_for_provider(
DATABRICKS_PROVIDER_NAME,
model_config,
system,
messages,
tools,
image_format,
)
}
pub fn create_request_for_provider(
provider_name: &str,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
image_format: &ImageFormat,
) -> anyhow::Result<Value, Error> {
if model_config.model_name.starts_with("o1-mini") {
return Err(anyhow!(
@@ -644,10 +671,10 @@ pub fn create_request(
}
if is_claude_model(&model_config.model_name) {
apply_claude_thinking_config(&mut payload, model_config);
apply_claude_thinking_config(&mut payload, provider_name, model_config);
} else {
// open ai reasoning models currently don't support temperature
if !is_openai_reasoning_model {
if !is_openai_reasoning_model && model_supports_temperature(provider_name, model_config) {
if let Some(temp) = model_config.temperature {
payload
.as_object_mut()
@@ -1200,6 +1227,51 @@ mod tests {
Ok(())
}
#[test]
fn test_create_request_adaptive_thinking_for_new_anthropic_models() -> anyhow::Result<()> {
let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]);
for name in [
"databricks-claude-opus-4-7",
"databricks-claude-opus-4-8",
"databricks-claude-fable-5",
"global.anthropic.claude-fable-5",
] {
let mut model_config = ModelConfig::new_or_fail(name);
model_config.max_tokens = Some(4096);
let mut params = std::collections::HashMap::new();
params.insert("thinking_effort".to_string(), serde_json::json!("high"));
model_config.request_params = Some(params);
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
assert_eq!(request["thinking"]["type"], "adaptive", "{name}");
assert!(request.get("temperature").is_none(), "{name}");
assert_eq!(request["max_completion_tokens"], 4096, "{name}");
assert!(request.get("max_tokens").is_none(), "{name}");
}
Ok(())
}
#[test]
fn test_create_request_always_on_adaptive_off_effort_falls_back_to_high() -> anyhow::Result<()>
{
let _guard = env_lock::lock_env([("GOOSE_THINKING_EFFORT", None::<&str>)]);
let mut model_config = ModelConfig::new_or_fail("databricks-claude-fable-5");
model_config.max_tokens = Some(4096);
let mut params = std::collections::HashMap::new();
params.insert("thinking_effort".to_string(), serde_json::json!("off"));
model_config.request_params = Some(params);
let request = create_request(&model_config, "system", &[], &[], &ImageFormat::OpenAi)?;
assert_eq!(request["thinking"]["type"], "adaptive");
assert_eq!(request["output_config"]["effort"], "high");
Ok(())
}
#[test]
fn test_create_request_enabled_thinking_with_budget() -> anyhow::Result<()> {
let mut model_config = ModelConfig::new_or_fail("databricks-claude-3-7-sonnet");