mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat: [anthropic] Claude 3.7 Sonnet with extended thinking (#1370)
- only works with anthropic provider (wont work with databricks)
This commit is contained in:
@@ -107,8 +107,19 @@ pub fn render_message(message: &Message) {
|
||||
MessageContent::Image(image) => {
|
||||
println!("Image: [data: {}, type: {}]", image.data, image.mime_type);
|
||||
}
|
||||
MessageContent::Thinking(thinking) => {
|
||||
if std::env::var("GOOSE_CLI_SHOW_THINKING").is_ok() {
|
||||
println!("\n{}", style("Thinking:").dim().italic());
|
||||
print_markdown(&thinking.thinking, theme);
|
||||
}
|
||||
}
|
||||
MessageContent::RedactedThinking(_) => {
|
||||
// For redacted thinking, print thinking was redacted
|
||||
println!("\n{}", style("Thinking:").dim().italic());
|
||||
print_markdown("Thinking was redacted", theme);
|
||||
}
|
||||
_ => {
|
||||
println!("Message type could not be rendered");
|
||||
println!("WARNING: Message content type could not be rendered");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,17 @@ pub struct ToolConfirmationRequest {
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ThinkingContent {
|
||||
pub thinking: String,
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RedactedThinkingContent {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
/// Content passed inside a message, which can be both simple content and tool content
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
@@ -52,6 +63,8 @@ pub enum MessageContent {
|
||||
ToolRequest(ToolRequest),
|
||||
ToolResponse(ToolResponse),
|
||||
ToolConfirmationRequest(ToolConfirmationRequest),
|
||||
Thinking(ThinkingContent),
|
||||
RedactedThinking(RedactedThinkingContent),
|
||||
}
|
||||
|
||||
impl MessageContent {
|
||||
@@ -97,6 +110,17 @@ impl MessageContent {
|
||||
prompt,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thinking<S1: Into<String>, S2: Into<String>>(thinking: S1, signature: S2) -> Self {
|
||||
MessageContent::Thinking(ThinkingContent {
|
||||
thinking: thinking.into(),
|
||||
signature: signature.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn redacted_thinking<S: Into<String>>(data: S) -> Self {
|
||||
MessageContent::RedactedThinking(RedactedThinkingContent { data: data.into() })
|
||||
}
|
||||
pub fn as_tool_request(&self) -> Option<&ToolRequest> {
|
||||
if let MessageContent::ToolRequest(ref tool_request) = self {
|
||||
Some(tool_request)
|
||||
@@ -143,6 +167,22 @@ impl MessageContent {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the thinking content if this is a ThinkingContent variant
|
||||
pub fn as_thinking(&self) -> Option<&ThinkingContent> {
|
||||
match self {
|
||||
MessageContent::Thinking(thinking) => Some(thinking),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the redacted thinking content if this is a RedactedThinkingContent variant
|
||||
pub fn as_redacted_thinking(&self) -> Option<&RedactedThinkingContent> {
|
||||
match self {
|
||||
MessageContent::RedactedThinking(redacted) => Some(redacted),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Content> for MessageContent {
|
||||
@@ -264,6 +304,20 @@ impl Message {
|
||||
))
|
||||
}
|
||||
|
||||
/// Add thinking content to the message
|
||||
pub fn with_thinking<S1: Into<String>, S2: Into<String>>(
|
||||
self,
|
||||
thinking: S1,
|
||||
signature: S2,
|
||||
) -> Self {
|
||||
self.with_content(MessageContent::thinking(thinking, signature))
|
||||
}
|
||||
|
||||
/// Add redacted thinking content to the message
|
||||
pub fn with_redacted_thinking<S: Into<String>>(self, data: S) -> Self {
|
||||
self.with_content(MessageContent::redacted_thinking(data))
|
||||
}
|
||||
|
||||
/// Get the concatenated text content of the message, separated by newlines
|
||||
pub fn as_concat_text(&self) -> String {
|
||||
self.content
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use axum::http::HeaderMap;
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
@@ -17,6 +18,8 @@ pub const ANTHROPIC_KNOWN_MODELS: &[&str] = &[
|
||||
"claude-3-5-sonnet-latest",
|
||||
"claude-3-5-haiku-latest",
|
||||
"claude-3-opus-latest",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-7-sonnet-latest",
|
||||
];
|
||||
|
||||
pub const ANTHROPIC_DOC_URL: &str = "https://docs.anthropic.com/en/docs/about-claude/models";
|
||||
@@ -57,7 +60,7 @@ impl AnthropicProvider {
|
||||
})
|
||||
}
|
||||
|
||||
async fn post(&self, payload: Value) -> Result<Value, ProviderError> {
|
||||
async fn post(&self, headers: HeaderMap, payload: Value) -> Result<Value, ProviderError> {
|
||||
let base_url = url::Url::parse(&self.host)
|
||||
.map_err(|e| ProviderError::RequestFailed(format!("Invalid base URL: {e}")))?;
|
||||
let url = base_url.join("v1/messages").map_err(|e| {
|
||||
@@ -67,8 +70,7 @@ impl AnthropicProvider {
|
||||
let response = self
|
||||
.client
|
||||
.post(url)
|
||||
.header("x-api-key", &self.api_key)
|
||||
.header("anthropic-version", "2023-06-01")
|
||||
.headers(headers)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
@@ -155,8 +157,22 @@ impl Provider for AnthropicProvider {
|
||||
) -> Result<(Message, ProviderUsage), ProviderError> {
|
||||
let payload = create_request(&self.model, system, messages, tools)?;
|
||||
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert("x-api-key", self.api_key.parse().unwrap());
|
||||
headers.insert("anthropic-version", "2023-06-01".parse().unwrap());
|
||||
|
||||
let is_thinking_enabled = std::env::var("ANTHROPIC_THINKING_ENABLED").is_ok();
|
||||
if self.model.model_name.starts_with("claude-3-7-sonnet-") && is_thinking_enabled {
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/tool-use/token-efficient-tool-use
|
||||
headers.insert(
|
||||
"anthropic-beta",
|
||||
"token-efficient-tools-2025-02-19".parse().unwrap(),
|
||||
);
|
||||
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#extended-output-capabilities-beta
|
||||
headers.insert("anthropic-beta", "output-128k-2025-02-19".parse().unwrap());
|
||||
}
|
||||
// Make request
|
||||
let response = self.post(payload.clone()).await?;
|
||||
let response = self.post(headers, payload.clone()).await?;
|
||||
|
||||
// Parse response
|
||||
let message = response_to_message(response.clone())?;
|
||||
|
||||
@@ -60,6 +60,19 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
|
||||
MessageContent::ToolConfirmationRequest(_tool_confirmation_request) => {
|
||||
// Skip tool confirmation requests
|
||||
}
|
||||
MessageContent::Thinking(thinking) => {
|
||||
content.push(json!({
|
||||
"type": "thinking",
|
||||
"thinking": thinking.thinking,
|
||||
"signature": thinking.signature
|
||||
}));
|
||||
}
|
||||
MessageContent::RedactedThinking(redacted) => {
|
||||
content.push(json!({
|
||||
"type": "redacted_thinking",
|
||||
"data": redacted.data
|
||||
}));
|
||||
}
|
||||
MessageContent::Image(_) => continue, // Anthropic doesn't support image content yet
|
||||
}
|
||||
}
|
||||
@@ -179,6 +192,24 @@ pub fn response_to_message(response: Value) -> Result<Message> {
|
||||
let tool_call = ToolCall::new(name, input.clone());
|
||||
message = message.with_tool_request(id, Ok(tool_call));
|
||||
}
|
||||
Some("thinking") => {
|
||||
let thinking = block
|
||||
.get("thinking")
|
||||
.and_then(|t| t.as_str())
|
||||
.ok_or_else(|| anyhow!("Missing thinking content"))?;
|
||||
let signature = block
|
||||
.get("signature")
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or_else(|| anyhow!("Missing thinking signature"))?;
|
||||
message = message.with_thinking(thinking, signature);
|
||||
}
|
||||
Some("redacted_thinking") => {
|
||||
let data = block
|
||||
.get("data")
|
||||
.and_then(|d| d.as_str())
|
||||
.ok_or_else(|| anyhow!("Missing redacted_thinking data"))?;
|
||||
message = message.with_redacted_thinking(data);
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
@@ -243,10 +274,13 @@ pub fn create_request(
|
||||
return Err(anyhow!("No valid messages to send to Anthropic API"));
|
||||
}
|
||||
|
||||
// https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table
|
||||
// Claude 3.7 supports max output tokens up to 8192
|
||||
let max_tokens = model_config.max_tokens.unwrap_or(8192);
|
||||
let mut payload = json!({
|
||||
"model": model_config.model_name,
|
||||
"messages": anthropic_messages,
|
||||
"max_tokens": model_config.max_tokens.unwrap_or(4096)
|
||||
"max_tokens": max_tokens,
|
||||
});
|
||||
|
||||
// Add system message if present
|
||||
@@ -265,12 +299,38 @@ pub fn create_request(
|
||||
.insert("tools".to_string(), json!(tool_specs));
|
||||
}
|
||||
|
||||
// Add temperature if specified
|
||||
// Add temperature if specified and not using extended thinking model
|
||||
if let Some(temp) = model_config.temperature {
|
||||
// Claude 3.7 models with thinking enabled don't support temperature
|
||||
if !model_config.model_name.starts_with("claude-3-7-sonnet-") {
|
||||
payload
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("temperature".to_string(), json!(temp));
|
||||
}
|
||||
}
|
||||
|
||||
// Add thinking parameters for claude-3-7-sonnet model
|
||||
let is_thinking_enabled = std::env::var("ANTHROPIC_THINKING_ENABLED").is_ok();
|
||||
if model_config.model_name.starts_with("claude-3-7-sonnet-") && is_thinking_enabled {
|
||||
// Minimum budget_tokens is 1024
|
||||
let budget_tokens = std::env::var("ANTHROPIC_THINKING_BUDGET")
|
||||
.unwrap_or_else(|_| "16000".to_string())
|
||||
.parse()
|
||||
.unwrap_or(16000);
|
||||
|
||||
payload
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("temperature".to_string(), json!(temp));
|
||||
.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)
|
||||
@@ -361,6 +421,80 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_thinking_response() -> Result<()> {
|
||||
let response = json!({
|
||||
"id": "msg_456",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "thinking",
|
||||
"thinking": "This is a step-by-step thought process...",
|
||||
"signature": "EuYBCkQYAiJAVbJNBoH7HQiDcMwwAMhWqNyoe4G2xHRprK8ICM8gZzu16i7Se4EiEbmlKqNH1GtwcX1BMK6iLu8bxWn5wPVIFBIMnptdlVal7ZX5iNPFGgwWjX+BntcEOHky4HciMFVef7FpQeqnuiL1Xt7J4OLHZSyu4tcr809AxAbclcJ5dm1xE5gZrUO+/v60cnJM2ipQp4B8/3eHI03KSV6bZR/vMrBSYCV+aa/f5KHX2cRtLGp/Ba+3Tk/efbsg01WSduwAIbR4coVrZLnGJXNyVTFW/Be2kLy/ECZnx8cqvU3oQOg="
|
||||
},
|
||||
{
|
||||
"type": "redacted_thinking",
|
||||
"data": "EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "I've analyzed the problem and here's the solution."
|
||||
}
|
||||
],
|
||||
"model": "claude-3-7-sonnet-20250219",
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 45,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
}
|
||||
});
|
||||
|
||||
let message = response_to_message(response.clone())?;
|
||||
let usage = get_usage(&response)?;
|
||||
|
||||
assert_eq!(message.content.len(), 3);
|
||||
|
||||
if let MessageContent::Thinking(thinking) = &message.content[0] {
|
||||
assert_eq!(
|
||||
thinking.thinking,
|
||||
"This is a step-by-step thought process..."
|
||||
);
|
||||
assert!(thinking
|
||||
.signature
|
||||
.starts_with("EuYBCkQYAiJAVbJNBoH7HQiDcMwwAMhWqNyoe4G2xHRprK8ICM8g"));
|
||||
} else {
|
||||
panic!("Expected Thinking content at index 0");
|
||||
}
|
||||
|
||||
if let MessageContent::RedactedThinking(redacted) = &message.content[1] {
|
||||
assert_eq!(
|
||||
redacted.data,
|
||||
"EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP"
|
||||
);
|
||||
} else {
|
||||
panic!("Expected RedactedThinking content at index 1");
|
||||
}
|
||||
|
||||
if let MessageContent::Text(text) = &message.content[2] {
|
||||
assert_eq!(
|
||||
text.text,
|
||||
"I've analyzed the problem and here's the solution."
|
||||
);
|
||||
} else {
|
||||
panic!("Expected Text content at index 2");
|
||||
}
|
||||
|
||||
assert_eq!(usage.input_tokens, Some(10));
|
||||
assert_eq!(usage.output_tokens, Some(45));
|
||||
assert_eq!(usage.total_tokens, Some(55));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_to_anthropic_spec() {
|
||||
let messages = vec![
|
||||
@@ -436,4 +570,47 @@ mod tests {
|
||||
assert_eq!(spec_array[0]["text"], system);
|
||||
assert!(spec_array[0].get("cache_control").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_request_with_thinking() -> Result<()> {
|
||||
// Save the original env var value if it exists
|
||||
let original_value = std::env::var("ANTHROPIC_THINKING_ENABLED").ok();
|
||||
|
||||
// Set the env var for this test
|
||||
std::env::set_var("ANTHROPIC_THINKING_ENABLED", "true");
|
||||
|
||||
// Execute the test
|
||||
let result = (|| {
|
||||
let model_config = ModelConfig::new("claude-3-7-sonnet-20250219".to_string());
|
||||
let system = "You are a helpful assistant.";
|
||||
let messages = vec![Message::user().with_text("Hello")];
|
||||
let tools = vec![];
|
||||
|
||||
let payload = create_request(&model_config, system, &messages, &tools)?;
|
||||
|
||||
// Verify basic structure
|
||||
assert_eq!(payload["model"], "claude-3-7-sonnet-20250219");
|
||||
assert_eq!(payload["messages"][0]["role"], "user");
|
||||
assert_eq!(payload["messages"][0]["content"][0]["text"], "Hello");
|
||||
|
||||
// Verify thinking parameters
|
||||
assert!(payload.get("thinking").is_some());
|
||||
assert_eq!(payload["thinking"]["type"], "enabled");
|
||||
assert!(payload["thinking"]["budget_tokens"].as_i64().unwrap() >= 1024);
|
||||
|
||||
// Temperature should not be present for 3.7 models with thinking
|
||||
assert!(payload.get("temperature").is_none());
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
// Restore the original env var state
|
||||
match original_value {
|
||||
Some(val) => std::env::set_var("ANTHROPIC_THINKING_ENABLED", val),
|
||||
None => std::env::remove_var("ANTHROPIC_THINKING_ENABLED"),
|
||||
}
|
||||
|
||||
// Return the test result
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result<bedrock::C
|
||||
MessageContent::Image(_) => {
|
||||
bail!("Image content is not supported by Bedrock provider yet")
|
||||
}
|
||||
MessageContent::Thinking(_) => {
|
||||
// Thinking blocks are not supported in Bedrock - skip
|
||||
bedrock::ContentBlock::Text("".to_string())
|
||||
}
|
||||
MessageContent::RedactedThinking(_) => {
|
||||
// Redacted thinking blocks are not supported in Bedrock - skip
|
||||
bedrock::ContentBlock::Text("".to_string())
|
||||
}
|
||||
MessageContent::ToolRequest(tool_req) => {
|
||||
let tool_use_id = tool_req.id.to_string();
|
||||
let tool_use = if let Ok(call) = tool_req.tool_call.as_ref() {
|
||||
|
||||
@@ -44,6 +44,14 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec<
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageContent::Thinking(_) => {
|
||||
// Thinking blocks are not directly used in OpenAI format
|
||||
continue;
|
||||
}
|
||||
MessageContent::RedactedThinking(_) => {
|
||||
// Redacted thinking blocks are not directly used in OpenAI format
|
||||
continue;
|
||||
}
|
||||
MessageContent::ToolRequest(request) => match &request.tool_call {
|
||||
Ok(tool_call) => {
|
||||
let sanitized_name = sanitize_function_name(&tool_call.name);
|
||||
|
||||
Reference in New Issue
Block a user