feat: support Anthropic adaptive thinking (#7356)

Signed-off-by: rabi <ramishra@redhat.com>
This commit is contained in:
Rabi Mishra
2026-02-26 21:27:13 +05:30
committed by GitHub
parent 835a1af608
commit 08e95b21f1
8 changed files with 477 additions and 37 deletions
@@ -22,6 +22,7 @@ use goose::config::{
use goose::model::ModelConfig;
use goose::posthog::{get_telemetry_choice, TELEMETRY_ENABLED_KEY};
use goose::providers::base::ConfigKey;
use goose::providers::formats::anthropic::supports_adaptive_thinking;
use goose::providers::provider_test::test_provider_configuration;
use goose::providers::{create, providers, retry_operation, RetryConfig};
use goose::session::SessionType;
@@ -761,6 +762,53 @@ pub async fn configure_provider_dialog() -> anyhow::Result<bool> {
config.set_gemini3_thinking_level(thinking_level)?;
}
if model.to_lowercase().starts_with("claude-") {
let supports_adaptive = supports_adaptive_thinking(&model);
let mut thinking_select = cliclack::select("Select extended thinking mode for Claude:");
if supports_adaptive {
thinking_select = thinking_select.item(
"adaptive",
"Adaptive - Claude decides when and how much to think (recommended)",
"",
);
}
thinking_select = thinking_select
.item("enabled", "Enabled - Fixed token budget for thinking", "")
.item("disabled", "Disabled - No extended thinking", "");
if supports_adaptive {
thinking_select = thinking_select.initial_value("adaptive");
} else {
thinking_select = thinking_select.initial_value("disabled");
}
let thinking_type: &str = thinking_select.interact()?;
config.set_claude_thinking_type(thinking_type)?;
if thinking_type == "adaptive" {
let effort: &str = cliclack::select("Select adaptive thinking effort level:")
.item("low", "Low - Minimal thinking, fastest responses", "")
.item("medium", "Medium - Moderate thinking", "")
.item("high", "High - Deep reasoning (default)", "")
.item(
"max",
"Max - No constraints on thinking depth (Opus 4.6 only)",
"",
)
.initial_value("high")
.interact()?;
config.set_claude_thinking_effort(effort)?;
} else if thinking_type == "enabled" {
let budget: String = cliclack::input("Enter thinking budget (tokens):")
.default_input("16000")
.validate(|input: &String| match input.parse::<i32>() {
Ok(n) if n > 0 => Ok(()),
_ => Err("Please enter a valid positive number"),
})
.interact()?;
config.set_claude_thinking_budget(budget.parse::<i32>()?)?;
}
}
// Test the configuration
let spin = spinner();
spin.start("Checking your configuration...");
+3
View File
@@ -1028,6 +1028,9 @@ config_value!(GOOSE_PROMPT_EDITOR, Option<String>);
config_value!(GOOSE_MAX_ACTIVE_AGENTS, usize);
config_value!(GOOSE_DISABLE_SESSION_NAMING, bool);
config_value!(GEMINI3_THINKING_LEVEL, String);
config_value!(CLAUDE_THINKING_TYPE, String);
config_value!(CLAUDE_THINKING_EFFORT, String);
config_value!(CLAUDE_THINKING_BUDGET, i32);
fn find_workspace_or_exe_root() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
+53 -1
View File
@@ -44,7 +44,7 @@ pub enum ConfigError {
InvalidRange(String, String),
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, ToSchema)]
pub struct ModelConfig {
pub model_name: String,
pub context_limit: Option<usize>,
@@ -283,6 +283,22 @@ impl ModelConfig {
4_096
}
pub fn get_config_param<T: for<'de> serde::Deserialize<'de>>(
&self,
request_key: &str,
config_key: &str,
) -> Option<T> {
self.request_params
.as_ref()
.and_then(|params| params.get(request_key))
.and_then(|v| serde_json::from_value(v.clone()).ok())
.or_else(|| {
crate::config::Config::global()
.get_param::<T>(config_key)
.ok()
})
}
pub fn new_or_fail(model_name: &str) -> ModelConfig {
ModelConfig::new(model_name)
.unwrap_or_else(|_| panic!("Failed to create model config for {}", model_name))
@@ -356,4 +372,40 @@ mod tests {
let config = ModelConfig::new("test-model").unwrap();
assert_eq!(config.max_tokens, None);
}
#[test]
fn test_get_config_param() {
let _guard = env_lock::lock_env([
("CLAUDE_THINKING_EFFORT", Some("high")),
("CLAUDE_THINKING_TYPE", None::<&str>),
]);
let mut params = HashMap::new();
params.insert("effort".to_string(), serde_json::json!("low"));
let config_with_params = ModelConfig {
model_name: "test".to_string(),
request_params: Some(params),
..Default::default()
};
let config_without_params = ModelConfig {
request_params: None,
..config_with_params.clone()
};
assert_eq!(
config_with_params.get_config_param::<String>("effort", "CLAUDE_THINKING_EFFORT"),
Some("low".to_string())
);
assert_eq!(
config_without_params.get_config_param::<String>("effort", "CLAUDE_THINKING_EFFORT"),
Some("high".to_string())
);
assert_eq!(
config_without_params
.get_config_param::<String>("nonexistent", "NONEXISTENT_CONFIG_KEY"),
None
);
}
}
+7 -3
View File
@@ -11,7 +11,9 @@ use tokio_util::io::StreamReader;
use super::api_client::{ApiClient, AuthMethod};
use super::base::{ConfigKey, MessageStream, ModelInfo, Provider, ProviderDef, ProviderMetadata};
use super::errors::ProviderError;
use super::formats::anthropic::{create_request, response_to_streaming_message};
use super::formats::anthropic::{
create_request, response_to_streaming_message, thinking_type, ThinkingType,
};
use super::openai_compatible::handle_status_openai_compat;
use super::openai_compatible::map_http_error_to_provider_error;
use crate::config::declarative_providers::DeclarativeProviderConfig;
@@ -25,6 +27,9 @@ 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] = &[
// Claude 4.6 models
"claude-opus-4-6",
"claude-sonnet-4-6",
// Claude 4.5 models with aliases
"claude-sonnet-4-5",
"claude-sonnet-4-5-20250929",
@@ -124,9 +129,8 @@ impl AnthropicProvider {
fn get_conditional_headers(&self) -> Vec<(&str, &str)> {
let mut headers = Vec::new();
let is_thinking_enabled = std::env::var("CLAUDE_THINKING_ENABLED").is_ok();
if self.model.model_name.starts_with("claude-3-7-sonnet-") {
if is_thinking_enabled {
if thinking_type(&self.model) == ThinkingType::Enabled {
headers.push(("anthropic-beta", "output-128k-2025-02-19"));
}
headers.push(("anthropic-beta", "token-efficient-tools-2025-02-19"));
+239 -22
View File
@@ -8,8 +8,78 @@ use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData, JsonObjec
use rmcp::object as json_object;
use serde_json::{json, Value};
use std::collections::HashSet;
use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
macro_rules! string_enum {
($name:ident { $($variant:ident => $str:literal),+ $(,)? }) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $name { $($variant),+ }
impl FromStr for $name {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
$($str => Ok(Self::$variant),)+
other => Err(format!("unknown {}: '{other}'", stringify!($name))),
}
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { $(Self::$variant => write!(f, $str),)+ }
}
}
}
}
string_enum!(ThinkingType { Adaptive => "adaptive", Enabled => "enabled", Disabled => "disabled" });
string_enum!(ThinkingEffort { Low => "low", Medium => "medium", High => "high", Max => "max" });
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")
}
pub fn thinking_type(model_config: &ModelConfig) -> ThinkingType {
let model_lower = model_config.model_name.to_lowercase();
if !model_lower.contains("claude") {
return ThinkingType::Disabled;
}
let is_adaptive_model = supports_adaptive_thinking(&model_config.model_name);
if let Some(s) =
model_config.get_config_param::<String>("thinking_type", "CLAUDE_THINKING_TYPE")
{
let tt = s.parse::<ThinkingType>().unwrap_or_else(|e| {
tracing::warn!("{e}");
ThinkingType::Disabled
});
if tt == ThinkingType::Adaptive && !is_adaptive_model {
tracing::warn!(
"Adaptive thinking not supported for {}, disabling thinking",
model_config.model_name
);
return ThinkingType::Disabled;
}
return tt;
}
if is_adaptive_model {
ThinkingType::Adaptive
} else if std::env::var("CLAUDE_THINKING_ENABLED").is_ok() {
tracing::warn!(
"CLAUDE_THINKING_ENABLED is deprecated, use CLAUDE_THINKING_TYPE=enabled instead"
);
ThinkingType::Enabled
} else {
ThinkingType::Disabled
}
}
// Constants for frequently used strings in Anthropic API format
const TYPE_FIELD: &str = "type";
const CONTENT_FIELD: &str = "content";
@@ -386,6 +456,43 @@ pub fn get_usage(data: &Value) -> Result<Usage> {
}
}
pub fn thinking_effort(model_config: &ModelConfig) -> ThinkingEffort {
match model_config.get_config_param::<String>("effort", "CLAUDE_THINKING_EFFORT") {
Some(s) => s.parse().unwrap_or_else(|e| {
tracing::warn!("{e}, defaulting to 'high'");
ThinkingEffort::High
}),
None => ThinkingEffort::High,
}
}
fn apply_thinking_config(payload: &mut Value, model_config: &ModelConfig, max_tokens: i32) {
let obj = payload.as_object_mut().unwrap();
match thinking_type(model_config) {
ThinkingType::Adaptive => {
obj.insert("thinking".to_string(), json!({"type": "adaptive"}));
let effort = thinking_effort(model_config).to_string();
obj.insert("output_config".to_string(), json!({"effort": effort}));
}
ThinkingType::Enabled => {
let budget_tokens = model_config
.get_config_param::<i32>("budget_tokens", "CLAUDE_THINKING_BUDGET")
.unwrap_or(16000)
.max(1024);
obj.insert("max_tokens".to_string(), json!(max_tokens + budget_tokens));
obj.insert(
"thinking".to_string(),
json!({
"type": "enabled",
"budget_tokens": budget_tokens
}),
);
}
ThinkingType::Disabled => {}
}
}
/// Create a complete request payload for Anthropic's API
pub fn create_request(
model_config: &ModelConfig,
@@ -429,29 +536,8 @@ pub fn create_request(
.insert("temperature".to_string(), json!(temp));
}
let is_thinking_enabled = std::env::var("CLAUDE_THINKING_ENABLED").is_ok();
if is_thinking_enabled {
// Anthropic requires budget_tokens >= 1024
const DEFAULT_THINKING_BUDGET: i32 = 16000;
let raw_budget_tokens: i32 = std::env::var("CLAUDE_THINKING_BUDGET")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_THINKING_BUDGET);
let budget_tokens: i32 = std::cmp::max(1024, raw_budget_tokens);
apply_thinking_config(&mut payload, model_config, max_tokens);
payload
.as_object_mut()
.unwrap()
.insert("max_tokens".to_string(), json!(max_tokens + budget_tokens));
payload.as_object_mut().unwrap().insert(
"thinking".to_string(),
json!({
"type": "enabled",
"budget_tokens": budget_tokens
}),
);
}
Ok(payload)
}
@@ -701,6 +787,7 @@ where
mod tests {
use super::*;
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use rmcp::object;
use serde_json::json;
@@ -967,6 +1054,70 @@ mod tests {
Ok(())
}
#[test]
fn test_create_request_adaptive_thinking_for_46_models() -> Result<()> {
let _guard = env_lock::lock_env([
("CLAUDE_THINKING_TYPE", Some("adaptive")),
("CLAUDE_THINKING_EFFORT", Some("high")),
("CLAUDE_THINKING_ENABLED", None::<&str>),
]);
let mut config = cfg("claude-opus-4-6");
config.max_tokens = Some(4096);
let messages = vec![Message::user().with_text("Hello")];
let payload = create_request(&config, "system", &messages, &[])?;
assert_eq!(payload["thinking"]["type"], "adaptive");
assert_eq!(payload["output_config"]["effort"], "high");
assert!(payload.get("budget_tokens").is_none());
Ok(())
}
#[test]
fn test_create_request_enabled_thinking_with_budget() -> Result<()> {
let _guard = env_lock::lock_env([
("CLAUDE_THINKING_TYPE", None::<&str>),
("CLAUDE_THINKING_EFFORT", None::<&str>),
("CLAUDE_THINKING_ENABLED", None::<&str>),
("CLAUDE_THINKING_BUDGET", None::<&str>),
]);
let mut params = std::collections::HashMap::new();
params.insert("thinking_type".to_string(), json!("enabled"));
params.insert("budget_tokens".to_string(), json!(10000));
let mut config = cfg("claude-3-7-sonnet-20250219");
config.max_tokens = Some(4096);
config.request_params = Some(params);
let messages = vec![Message::user().with_text("Hello")];
let payload = create_request(&config, "system", &messages, &[])?;
assert_eq!(payload["thinking"]["type"], "enabled");
assert_eq!(payload["thinking"]["budget_tokens"], 10000);
assert_eq!(payload["max_tokens"], 4096 + 10000);
Ok(())
}
#[test]
fn test_create_request_disabled_thinking_no_thinking_field() -> Result<()> {
let _guard = env_lock::lock_env([
("CLAUDE_THINKING_TYPE", None::<&str>),
("CLAUDE_THINKING_ENABLED", None::<&str>),
]);
let config = cfg("claude-sonnet-4-20250514");
let messages = vec![Message::user().with_text("Hello")];
let payload = create_request(&config, "system", &messages, &[])?;
assert!(payload.get("thinking").is_none());
assert!(payload.get("output_config").is_none());
Ok(())
}
#[test]
fn test_tool_error_handling_maintains_pairing() {
use crate::conversation::message::Message;
@@ -1043,4 +1194,70 @@ mod tests {
assert_eq!(assistant_content.len(), 1);
assert_eq!(assistant_content[0]["type"], "tool_use");
}
fn cfg(name: &str) -> ModelConfig {
ModelConfig {
model_name: name.to_string(),
..Default::default()
}
}
fn cfg_with_thinking(name: &str, tt: &str) -> ModelConfig {
let mut params = std::collections::HashMap::new();
params.insert("thinking_type".to_string(), json!(tt));
ModelConfig {
model_name: name.to_string(),
request_params: Some(params),
..Default::default()
}
}
#[test]
fn test_thinking_type_explicit_params() {
assert_eq!(
thinking_type(&cfg_with_thinking("claude-opus-4-6", "adaptive")),
ThinkingType::Adaptive
);
assert_eq!(
thinking_type(&cfg_with_thinking("claude-opus-4-6", "disabled")),
ThinkingType::Disabled
);
assert_eq!(
thinking_type(&cfg_with_thinking("claude-3-7-sonnet-20250219", "enabled")),
ThinkingType::Enabled
);
assert_eq!(
thinking_type(&cfg_with_thinking("claude-3-7-sonnet-20250219", "adaptive")),
ThinkingType::Disabled
);
assert_eq!(
thinking_type(&cfg_with_thinking("claude-opus-4-6", "adapttive")),
ThinkingType::Disabled
);
}
#[test]
fn test_thinking_type_non_claude_always_disabled() {
assert_eq!(thinking_type(&cfg("gpt-4o")), ThinkingType::Disabled);
assert_eq!(
thinking_type(&cfg_with_thinking("gpt-4o", "enabled")),
ThinkingType::Disabled
);
}
#[test]
fn test_thinking_type_env_var_override() {
let _guard = env_lock::lock_env([
("CLAUDE_THINKING_TYPE", Some("adaptive")),
("CLAUDE_THINKING_ENABLED", None::<&str>),
]);
assert_eq!(
thinking_type(&cfg("claude-opus-4-6")),
ThinkingType::Adaptive
);
assert_eq!(
thinking_type(&cfg("claude-3-7-sonnet-20250219")),
ThinkingType::Disabled
);
}
}
@@ -65,6 +65,8 @@ pub enum ModelError {
pub const DEFAULT_MODEL: &str = "gemini-2.5-flash";
pub const KNOWN_MODELS: &[&str] = &[
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5@20251101",
"claude-sonnet-4-5@20250929",
"claude-opus-4-1@20250805",
+1 -10
View File
@@ -547,17 +547,8 @@ fn get_thinking_config(model_config: &ModelConfig) -> Option<ThinkingConfig> {
}
let thinking_level_str = model_config
.request_params
.as_ref()
.and_then(|params| params.get("thinking_level"))
.and_then(|v| v.as_str())
.get_config_param::<String>("thinking_level", "GEMINI3_THINKING_LEVEL")
.map(|s| s.to_lowercase())
.or_else(|| {
crate::config::Config::global()
.get_param::<String>("gemini3_thinking_level")
.ok()
.map(|s| s.to_lowercase())
})
.unwrap_or_else(|| "low".to_string());
let thinking_level = match thinking_level_str.as_str() {
@@ -26,6 +26,22 @@ const THINKING_LEVEL_OPTIONS = [
{ value: 'high', label: 'High - Deeper reasoning, higher latency' },
];
const CLAUDE_THINKING_EFFORT_OPTIONS = [
{ value: 'low', label: 'Low - Minimal thinking, fastest responses' },
{ value: 'medium', label: 'Medium - Moderate thinking' },
{ value: 'high', label: 'High - Deep reasoning (default)' },
{ value: 'max', label: 'Max - No constraints on thinking depth' },
];
function isClaudeModel(name: string | null | undefined): boolean {
return !!name && name.toLowerCase().startsWith('claude-');
}
function supportsAdaptiveThinking(name: string): boolean {
const lower = name.toLowerCase();
return lower.includes('claude-opus-4-6') || lower.includes('claude-sonnet-4-6');
}
const PREFERRED_MODEL_PATTERNS = [
/claude-sonnet-4/i,
/claude-4/i,
@@ -80,7 +96,7 @@ export const SwitchModelModal = ({
initialProvider,
titleOverride,
}: SwitchModelModalProps) => {
const { getProviders, read } = useConfig();
const { getProviders, read, upsert } = useConfig();
const { changeModel, currentModel, currentProvider } = useModelAndProvider();
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
type ModelOption = { value: string; label: string; provider: string; isDisabled?: boolean };
@@ -103,9 +119,41 @@ export const SwitchModelModal = ({
const [userClearedModel, setUserClearedModel] = useState(false);
const [providerErrors, setProviderErrors] = useState<Record<string, string>>({});
const [thinkingLevel, setThinkingLevel] = useState<string>('low');
const [claudeThinkingType, setClaudeThinkingType] = useState<string>('disabled');
const [claudeThinkingEffort, setClaudeThinkingEffort] = useState<string>('high');
const [claudeThinkingBudget, setClaudeThinkingBudget] = useState<string>('16000');
const modelName = usePredefinedModels ? selectedPredefinedModel?.name : model;
const isGemini3Model = modelName?.toLowerCase().startsWith('gemini-3') ?? false;
const showClaudeThinking = isClaudeModel(modelName);
const modelSupportsAdaptive = modelName ? supportsAdaptiveThinking(modelName) : false;
useEffect(() => {
if (!showClaudeThinking) return;
if (claudeThinkingType === 'adaptive' && !modelSupportsAdaptive) {
setClaudeThinkingType('disabled');
}
}, [modelName, showClaudeThinking, modelSupportsAdaptive, claudeThinkingType]);
useEffect(() => {
const readConfig = async (key: string): Promise<string | null> => {
try {
const val = (await read(key, false)) as string;
return val || null;
} catch (e) {
console.warn(`Could not read ${key}, using default:`, e);
return null;
}
};
(async () => {
const tt = await readConfig('CLAUDE_THINKING_TYPE');
if (tt) setClaudeThinkingType(tt);
const effort = await readConfig('CLAUDE_THINKING_EFFORT');
if (effort) setClaudeThinkingEffort(effort);
const budget = await readConfig('CLAUDE_THINKING_BUDGET');
if (budget) setClaudeThinkingBudget(budget);
})();
}, [read]);
// Validate form data
const validateForm = useCallback(() => {
@@ -167,6 +215,26 @@ export const SwitchModelModal = ({
};
}
if (showClaudeThinking) {
const params: Record<string, unknown> = {
...modelObj.request_params,
thinking_type: claudeThinkingType,
};
if (claudeThinkingType === 'adaptive') {
params.effort = claudeThinkingEffort;
} else if (claudeThinkingType === 'enabled') {
params.budget_tokens = parseInt(claudeThinkingBudget, 10) || 16000;
}
modelObj = { ...modelObj, request_params: params };
upsert('CLAUDE_THINKING_TYPE', claudeThinkingType, false).catch(console.warn);
if (claudeThinkingType === 'adaptive') {
upsert('CLAUDE_THINKING_EFFORT', claudeThinkingEffort, false).catch(console.warn);
} else if (claudeThinkingType === 'enabled') {
upsert('CLAUDE_THINKING_BUDGET', parseInt(claudeThinkingBudget, 10) || 16000, false).catch(console.warn);
}
}
await changeModel(sessionId, modelObj);
onModelSelected?.(modelObj.name);
@@ -364,6 +432,57 @@ export const SwitchModelModal = ({
}
};
const claudeThinkingTypeOptions = [
...(modelSupportsAdaptive
? [{ value: 'adaptive', label: 'Adaptive - Claude decides when and how much to think' }]
: []),
{ value: 'enabled', label: 'Enabled - Fixed token budget for thinking' },
{ value: 'disabled', label: 'Disabled - No extended thinking' },
];
const claudeThinkingControls = showClaudeThinking && (
<div className="mt-2 flex flex-col gap-3">
<div>
<label className="text-sm text-textSubtle mb-1 block">Extended Thinking</label>
<Select
options={claudeThinkingTypeOptions}
value={claudeThinkingTypeOptions.find((o) => o.value === claudeThinkingType)}
onChange={(newValue: unknown) => {
const option = newValue as { value: string; label: string } | null;
setClaudeThinkingType(option?.value || 'disabled');
}}
placeholder="Select thinking mode"
/>
</div>
{claudeThinkingType === 'adaptive' && (
<div>
<label className="text-sm text-textSubtle mb-1 block">Thinking Effort</label>
<Select
options={CLAUDE_THINKING_EFFORT_OPTIONS}
value={CLAUDE_THINKING_EFFORT_OPTIONS.find((o) => o.value === claudeThinkingEffort)}
onChange={(newValue: unknown) => {
const option = newValue as { value: string; label: string } | null;
setClaudeThinkingEffort(option?.value || 'high');
}}
placeholder="Select effort level"
/>
</div>
)}
{claudeThinkingType === 'enabled' && (
<div>
<label className="text-sm text-textSubtle mb-1 block">Thinking Budget (tokens)</label>
<Input
className="border-2 px-4 py-2"
type="number"
min="1024"
value={claudeThinkingBudget}
onChange={(e) => setClaudeThinkingBudget(e.target.value)}
/>
</div>
)}
</div>
);
return (
<Dialog open={true} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
@@ -455,6 +574,8 @@ export const SwitchModelModal = ({
/>
</div>
)}
{claudeThinkingControls}
</div>
) : (
/* Manual Provider/Model Selection */
@@ -600,6 +721,8 @@ export const SwitchModelModal = ({
/>
</div>
)}
{claudeThinkingControls}
</>
)}
</div>