Detailed changes
@@ -684,13 +684,17 @@ dependencies = [
"anyhow",
"chrono",
"futures 0.3.31",
+ "gpui",
+ "gpui_tokio",
"http_client",
+ "reqwest_client",
"schemars",
"serde",
"serde_json",
"settings",
"strum 0.27.2",
"thiserror 2.0.17",
+ "tokio",
]
[[package]]
@@ -1161,6 +1161,7 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
thread.update(cx, |thread, cx| {
thread.set_model(model.clone(), cx);
thread.set_thinking_effort(effort.clone(), cx);
+ thread.set_thinking_enabled(model.supports_thinking(), cx);
});
update_settings_file(
@@ -1169,16 +1170,7 @@ impl acp_thread::AgentModelSelector for NativeAgentModelSelector {
move |settings, cx| {
let provider = model.provider_id().0.to_string();
let model = model.id().0.to_string();
- let enable_thinking = settings
- .agent
- .as_ref()
- .and_then(|agent| {
- agent
- .default_model
- .as_ref()
- .map(|default_model| default_model.enable_thinking)
- })
- .unwrap_or_else(|| thread.read(cx).thinking_enabled());
+ let enable_thinking = thread.read(cx).thinking_enabled();
settings
.agent
.get_or_insert_default()
@@ -1644,7 +1636,8 @@ mod internal_tests {
use fs::FakeFs;
use gpui::TestAppContext;
use indoc::formatdoc;
- use language_model::fake_provider::FakeLanguageModel;
+ use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider};
+ use language_model::{LanguageModelProviderId, LanguageModelProviderName};
use serde_json::json;
use settings::SettingsStore;
use util::{path, rel_path::rel_path};
@@ -1853,6 +1846,353 @@ mod internal_tests {
settings_json["agent"]["default_model"]["provider"],
json!("fake")
);
+
+ // Register a thinking model and select it.
+ cx.update(|cx| {
+ let thinking_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
+ "fake-corp",
+ "fake-thinking",
+ "Fake Thinking",
+ true,
+ ));
+ let thinking_provider = Arc::new(
+ FakeLanguageModelProvider::new(
+ LanguageModelProviderId::from("fake-corp".to_string()),
+ LanguageModelProviderName::from("Fake Corp".to_string()),
+ )
+ .with_models(vec![thinking_model]),
+ );
+ LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
+ registry.register_provider(thinking_provider, cx);
+ });
+ });
+ agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
+
+ let selector = connection.model_selector(&session_id).unwrap();
+ cx.update(|cx| selector.select_model(acp::ModelId::new("fake-corp/fake-thinking"), cx))
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ // Verify enable_thinking was written to settings as true.
+ let settings_content = fs.load(paths::settings_file()).await.unwrap();
+ let settings_json: serde_json::Value = serde_json::from_str(&settings_content).unwrap();
+ assert_eq!(
+ settings_json["agent"]["default_model"]["enable_thinking"],
+ json!(true),
+ "selecting a thinking model should persist enable_thinking: true to settings"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_select_model_updates_thinking_enabled(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.create_dir(paths::settings_file().parent().unwrap())
+ .await
+ .unwrap();
+ fs.insert_file(paths::settings_file(), b"{}".to_vec()).await;
+ let project = Project::test(fs.clone(), [], cx).await;
+
+ let thread_store = cx.new(|cx| ThreadStore::new(cx));
+ let agent = NativeAgent::new(
+ project.clone(),
+ thread_store,
+ Templates::new(),
+ None,
+ fs.clone(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let connection = NativeAgentConnection(agent.clone());
+
+ let acp_thread = cx
+ .update(|cx| {
+ Rc::new(connection.clone()).new_thread(project.clone(), Path::new("/a"), cx)
+ })
+ .await
+ .unwrap();
+ let session_id = cx.update(|cx| acp_thread.read(cx).session_id().clone());
+
+ // Register a second provider with a thinking model.
+ cx.update(|cx| {
+ let thinking_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
+ "fake-corp",
+ "fake-thinking",
+ "Fake Thinking",
+ true,
+ ));
+ let thinking_provider = Arc::new(
+ FakeLanguageModelProvider::new(
+ LanguageModelProviderId::from("fake-corp".to_string()),
+ LanguageModelProviderName::from("Fake Corp".to_string()),
+ )
+ .with_models(vec![thinking_model]),
+ );
+ LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
+ registry.register_provider(thinking_provider, cx);
+ });
+ });
+ // Refresh the agent's model list so it picks up the new provider.
+ agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
+
+ // Thread starts with thinking_enabled = false (the default).
+ agent.read_with(cx, |agent, _| {
+ let session = agent.sessions.get(&session_id).unwrap();
+ session.thread.read_with(cx, |thread, _| {
+ assert!(!thread.thinking_enabled(), "thinking defaults to false");
+ });
+ });
+
+ // Select the thinking model via select_model.
+ let selector = connection.model_selector(&session_id).unwrap();
+ cx.update(|cx| selector.select_model(acp::ModelId::new("fake-corp/fake-thinking"), cx))
+ .await
+ .unwrap();
+
+ // select_model should have enabled thinking based on the model's supports_thinking().
+ agent.read_with(cx, |agent, _| {
+ let session = agent.sessions.get(&session_id).unwrap();
+ session.thread.read_with(cx, |thread, _| {
+ assert!(
+ thread.thinking_enabled(),
+ "select_model should enable thinking when model supports it"
+ );
+ });
+ });
+
+ // Switch back to the non-thinking model.
+ let selector = connection.model_selector(&session_id).unwrap();
+ cx.update(|cx| selector.select_model(acp::ModelId::new("fake/fake"), cx))
+ .await
+ .unwrap();
+
+ // select_model should have disabled thinking.
+ agent.read_with(cx, |agent, _| {
+ let session = agent.sessions.get(&session_id).unwrap();
+ session.thread.read_with(cx, |thread, _| {
+ assert!(
+ !thread.thinking_enabled(),
+ "select_model should disable thinking when model does not support it"
+ );
+ });
+ });
+ }
+
+ #[gpui::test]
+ async fn test_loaded_thread_preserves_thinking_enabled(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/", json!({ "a": {} })).await;
+ let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
+ let thread_store = cx.new(|cx| ThreadStore::new(cx));
+ let agent = NativeAgent::new(
+ project.clone(),
+ thread_store.clone(),
+ Templates::new(),
+ None,
+ fs.clone(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let connection = Rc::new(NativeAgentConnection(agent.clone()));
+
+ // Register a thinking model.
+ let thinking_model = Arc::new(FakeLanguageModel::with_id_and_thinking(
+ "fake-corp",
+ "fake-thinking",
+ "Fake Thinking",
+ true,
+ ));
+ let thinking_provider = Arc::new(
+ FakeLanguageModelProvider::new(
+ LanguageModelProviderId::from("fake-corp".to_string()),
+ LanguageModelProviderName::from("Fake Corp".to_string()),
+ )
+ .with_models(vec![thinking_model.clone()]),
+ );
+ cx.update(|cx| {
+ LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
+ registry.register_provider(thinking_provider, cx);
+ });
+ });
+ agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
+
+ // Create a thread and select the thinking model.
+ let acp_thread = cx
+ .update(|cx| {
+ connection
+ .clone()
+ .new_thread(project.clone(), Path::new("/a"), cx)
+ })
+ .await
+ .unwrap();
+ let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+ let selector = connection.model_selector(&session_id).unwrap();
+ cx.update(|cx| selector.select_model(acp::ModelId::new("fake-corp/fake-thinking"), cx))
+ .await
+ .unwrap();
+
+ // Verify thinking is enabled after selecting the thinking model.
+ let thread = agent.read_with(cx, |agent, _| {
+ agent.sessions.get(&session_id).unwrap().thread.clone()
+ });
+ thread.read_with(cx, |thread, _| {
+ assert!(
+ thread.thinking_enabled(),
+ "thinking should be enabled after selecting thinking model"
+ );
+ });
+
+ // Send a message so the thread gets persisted.
+ let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
+ let send = cx.foreground_executor().spawn(send);
+ cx.run_until_parked();
+
+ thinking_model.send_last_completion_stream_text_chunk("Response.");
+ thinking_model.end_last_completion_stream();
+
+ send.await.unwrap();
+ cx.run_until_parked();
+
+ // Drop the thread so it can be reloaded from disk.
+ cx.update(|_| {
+ drop(thread);
+ drop(acp_thread);
+ });
+ agent.read_with(cx, |agent, _| {
+ assert!(agent.sessions.is_empty());
+ });
+
+ // Reload the thread and verify thinking_enabled is still true.
+ let reloaded_acp_thread = agent
+ .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+ .await
+ .unwrap();
+ let reloaded_thread = agent.read_with(cx, |agent, _| {
+ agent.sessions.get(&session_id).unwrap().thread.clone()
+ });
+ reloaded_thread.read_with(cx, |thread, _| {
+ assert!(
+ thread.thinking_enabled(),
+ "thinking_enabled should be preserved when reloading a thread with a thinking model"
+ );
+ });
+
+ drop(reloaded_acp_thread);
+ }
+
+ #[gpui::test]
+ async fn test_loaded_thread_preserves_model(cx: &mut TestAppContext) {
+ init_test(cx);
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/", json!({ "a": {} })).await;
+ let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
+ let thread_store = cx.new(|cx| ThreadStore::new(cx));
+ let agent = NativeAgent::new(
+ project.clone(),
+ thread_store.clone(),
+ Templates::new(),
+ None,
+ fs.clone(),
+ &mut cx.to_async(),
+ )
+ .await
+ .unwrap();
+ let connection = Rc::new(NativeAgentConnection(agent.clone()));
+
+ // Register a model where id() != name(), like real Anthropic models
+ // (e.g. id="claude-sonnet-4-5-thinking-latest", name="Claude Sonnet 4.5 Thinking").
+ let model = Arc::new(FakeLanguageModel::with_id_and_thinking(
+ "fake-corp",
+ "custom-model-id",
+ "Custom Model Display Name",
+ false,
+ ));
+ let provider = Arc::new(
+ FakeLanguageModelProvider::new(
+ LanguageModelProviderId::from("fake-corp".to_string()),
+ LanguageModelProviderName::from("Fake Corp".to_string()),
+ )
+ .with_models(vec![model.clone()]),
+ );
+ cx.update(|cx| {
+ LanguageModelRegistry::global(cx).update(cx, |registry, cx| {
+ registry.register_provider(provider, cx);
+ });
+ });
+ agent.update(cx, |agent, cx| agent.models.refresh_list(cx));
+
+ // Create a thread and select the model.
+ let acp_thread = cx
+ .update(|cx| {
+ connection
+ .clone()
+ .new_thread(project.clone(), Path::new("/a"), cx)
+ })
+ .await
+ .unwrap();
+ let session_id = acp_thread.read_with(cx, |thread, _| thread.session_id().clone());
+
+ let selector = connection.model_selector(&session_id).unwrap();
+ cx.update(|cx| selector.select_model(acp::ModelId::new("fake-corp/custom-model-id"), cx))
+ .await
+ .unwrap();
+
+ let thread = agent.read_with(cx, |agent, _| {
+ agent.sessions.get(&session_id).unwrap().thread.clone()
+ });
+ thread.read_with(cx, |thread, _| {
+ assert_eq!(
+ thread.model().unwrap().id().0.as_ref(),
+ "custom-model-id",
+ "model should be set before persisting"
+ );
+ });
+
+ // Send a message so the thread gets persisted.
+ let send = acp_thread.update(cx, |thread, cx| thread.send(vec!["Hello".into()], cx));
+ let send = cx.foreground_executor().spawn(send);
+ cx.run_until_parked();
+
+ model.send_last_completion_stream_text_chunk("Response.");
+ model.end_last_completion_stream();
+
+ send.await.unwrap();
+ cx.run_until_parked();
+
+ // Drop the thread so it can be reloaded from disk.
+ cx.update(|_| {
+ drop(thread);
+ drop(acp_thread);
+ });
+ agent.read_with(cx, |agent, _| {
+ assert!(agent.sessions.is_empty());
+ });
+
+ // Reload the thread and verify the model was preserved.
+ let reloaded_acp_thread = agent
+ .update(cx, |agent, cx| agent.open_thread(session_id.clone(), cx))
+ .await
+ .unwrap();
+ let reloaded_thread = agent.read_with(cx, |agent, _| {
+ agent.sessions.get(&session_id).unwrap().thread.clone()
+ });
+ reloaded_thread.read_with(cx, |thread, _| {
+ let reloaded_model = thread
+ .model()
+ .expect("model should be present after reload");
+ assert_eq!(
+ reloaded_model.id().0.as_ref(),
+ "custom-model-id",
+ "reloaded thread should have the same model, not fall back to the default"
+ );
+ });
+
+ drop(reloaded_acp_thread);
}
#[gpui::test]
@@ -1093,10 +1093,6 @@ impl Thread {
let profile_id = db_thread
.profile
.unwrap_or_else(|| settings.default_profile.clone());
- let enable_thinking = settings
- .default_model
- .as_ref()
- .is_some_and(|model| model.enable_thinking);
let thinking_effort = settings
.default_model
.as_ref()
@@ -1129,6 +1125,12 @@ impl Thread {
watch::channel(Self::prompt_capabilities(model.as_deref()));
let action_log = cx.new(|_| ActionLog::new(project.clone()));
+ // TODO: We should serialize the user's configured thinking parameter on `DbThread`
+ // rather than deriving it from the model's capability. A user may have explicitly
+ // toggled thinking off for a model that supports it, and we'd lose that preference here.
+ let enable_thinking = model
+ .as_deref()
+ .is_some_and(|model| model.supports_thinking());
Self {
id,
@@ -1156,7 +1158,6 @@ impl Thread {
templates,
model,
summarization_model: None,
- // TODO: Should we persist this on the `DbThread`?
thinking_enabled: enable_thinking,
thinking_effort,
project,
@@ -1183,7 +1184,7 @@ impl Thread {
request_token_usage: self.request_token_usage.clone(),
model: self.model.as_ref().map(|model| DbLanguageModel {
provider: model.provider_id().to_string(),
- model: model.name().0.to_string(),
+ model: model.id().0.to_string(),
}),
profile: Some(self.profile_id.clone()),
imported: self.imported,
@@ -26,3 +26,9 @@ serde_json.workspace = true
settings.workspace = true
strum.workspace = true
thiserror.workspace = true
+
+[dev-dependencies]
+reqwest_client.workspace = true
+gpui_tokio.workspace = true
+gpui.workspace = true
+tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
@@ -19,6 +19,7 @@ use std::sync::{
pub struct FakeLanguageModelProvider {
id: LanguageModelProviderId,
name: LanguageModelProviderName,
+ models: Vec<Arc<dyn LanguageModel>>,
}
impl Default for FakeLanguageModelProvider {
@@ -26,6 +27,7 @@ impl Default for FakeLanguageModelProvider {
Self {
id: LanguageModelProviderId::from("fake".to_string()),
name: LanguageModelProviderName::from("Fake".to_string()),
+ models: vec![Arc::new(FakeLanguageModel::default())],
}
}
}
@@ -48,15 +50,15 @@ impl LanguageModelProvider for FakeLanguageModelProvider {
}
fn default_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
- Some(Arc::new(FakeLanguageModel::default()))
+ self.models.first().cloned()
}
fn default_fast_model(&self, _cx: &App) -> Option<Arc<dyn LanguageModel>> {
- Some(Arc::new(FakeLanguageModel::default()))
+ self.models.first().cloned()
}
fn provided_models(&self, _: &App) -> Vec<Arc<dyn LanguageModel>> {
- vec![Arc::new(FakeLanguageModel::default())]
+ self.models.clone()
}
fn is_authenticated(&self, _: &App) -> bool {
@@ -83,7 +85,16 @@ impl LanguageModelProvider for FakeLanguageModelProvider {
impl FakeLanguageModelProvider {
pub fn new(id: LanguageModelProviderId, name: LanguageModelProviderName) -> Self {
- Self { id, name }
+ Self {
+ id,
+ name,
+ models: vec![Arc::new(FakeLanguageModel::default())],
+ }
+ }
+
+ pub fn with_models(mut self, models: Vec<Arc<dyn LanguageModel>>) -> Self {
+ self.models = models;
+ self
}
pub fn test_model(&self) -> FakeLanguageModel {
@@ -100,6 +111,8 @@ pub struct ToolUseRequest {
}
pub struct FakeLanguageModel {
+ id: LanguageModelId,
+ name: LanguageModelName,
provider_id: LanguageModelProviderId,
provider_name: LanguageModelProviderName,
current_completion_txs: Mutex<
@@ -111,20 +124,39 @@ pub struct FakeLanguageModel {
)>,
>,
forbid_requests: AtomicBool,
+ supports_thinking: AtomicBool,
}
impl Default for FakeLanguageModel {
fn default() -> Self {
Self {
+ id: LanguageModelId::from("fake".to_string()),
+ name: LanguageModelName::from("Fake".to_string()),
provider_id: LanguageModelProviderId::from("fake".to_string()),
provider_name: LanguageModelProviderName::from("Fake".to_string()),
current_completion_txs: Mutex::new(Vec::new()),
forbid_requests: AtomicBool::new(false),
+ supports_thinking: AtomicBool::new(false),
}
}
}
impl FakeLanguageModel {
+ pub fn with_id_and_thinking(
+ provider_id: &str,
+ id: &str,
+ name: &str,
+ supports_thinking: bool,
+ ) -> Self {
+ Self {
+ id: LanguageModelId::from(id.to_string()),
+ name: LanguageModelName::from(name.to_string()),
+ provider_id: LanguageModelProviderId::from(provider_id.to_string()),
+ supports_thinking: AtomicBool::new(supports_thinking),
+ ..Default::default()
+ }
+ }
+
pub fn allow_requests(&self) {
self.forbid_requests.store(false, SeqCst);
}
@@ -133,6 +165,10 @@ impl FakeLanguageModel {
self.forbid_requests.store(true, SeqCst);
}
+ pub fn set_supports_thinking(&self, supports: bool) {
+ self.supports_thinking.store(supports, SeqCst);
+ }
+
pub fn pending_completions(&self) -> Vec<LanguageModelRequest> {
self.current_completion_txs
.lock()
@@ -215,11 +251,11 @@ impl FakeLanguageModel {
impl LanguageModel for FakeLanguageModel {
fn id(&self) -> LanguageModelId {
- LanguageModelId::from("fake".to_string())
+ self.id.clone()
}
fn name(&self) -> LanguageModelName {
- LanguageModelName::from("Fake".to_string())
+ self.name.clone()
}
fn provider_id(&self) -> LanguageModelProviderId {
@@ -242,6 +278,10 @@ impl LanguageModel for FakeLanguageModel {
false
}
+ fn supports_thinking(&self) -> bool {
+ self.supports_thinking.load(SeqCst)
+ }
+
fn telemetry_id(&self) -> String {
"fake".to_string()
}
@@ -219,6 +219,82 @@ pub struct AnthropicModel {
request_limiter: RateLimiter,
}
+fn to_anthropic_content(content: MessageContent) -> Option<anthropic::RequestContent> {
+ match content {
+ MessageContent::Text(text) => {
+ let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
+ text.trim_end().to_string()
+ } else {
+ text
+ };
+ if !text.is_empty() {
+ Some(anthropic::RequestContent::Text {
+ text,
+ cache_control: None,
+ })
+ } else {
+ None
+ }
+ }
+ MessageContent::Thinking {
+ text: thinking,
+ signature,
+ } => {
+ if let Some(signature) = signature
+ && !thinking.is_empty()
+ {
+ Some(anthropic::RequestContent::Thinking {
+ thinking,
+ signature,
+ cache_control: None,
+ })
+ } else {
+ None
+ }
+ }
+ MessageContent::RedactedThinking(data) => {
+ if !data.is_empty() {
+ Some(anthropic::RequestContent::RedactedThinking { data })
+ } else {
+ None
+ }
+ }
+ MessageContent::Image(image) => Some(anthropic::RequestContent::Image {
+ source: anthropic::ImageSource {
+ source_type: "base64".to_string(),
+ media_type: "image/png".to_string(),
+ data: image.source.to_string(),
+ },
+ cache_control: None,
+ }),
+ MessageContent::ToolUse(tool_use) => Some(anthropic::RequestContent::ToolUse {
+ id: tool_use.id.to_string(),
+ name: tool_use.name.to_string(),
+ input: tool_use.input,
+ cache_control: None,
+ }),
+ MessageContent::ToolResult(tool_result) => Some(anthropic::RequestContent::ToolResult {
+ tool_use_id: tool_result.tool_use_id.to_string(),
+ is_error: tool_result.is_error,
+ content: match tool_result.content {
+ LanguageModelToolResultContent::Text(text) => {
+ ToolResultContent::Plain(text.to_string())
+ }
+ LanguageModelToolResultContent::Image(image) => {
+ ToolResultContent::Multipart(vec![ToolResultPart::Image {
+ source: anthropic::ImageSource {
+ source_type: "base64".to_string(),
+ media_type: "image/png".to_string(),
+ data: image.source.to_string(),
+ },
+ }])
+ }
+ },
+ cache_control: None,
+ }),
+ }
+}
+
/// Convert a LanguageModelRequest to an Anthropic CountTokensRequest.
pub fn into_anthropic_count_tokens_request(
request: LanguageModelRequest,
@@ -238,87 +314,17 @@ pub fn into_anthropic_count_tokens_request(
let anthropic_message_content: Vec<anthropic::RequestContent> = message
.content
.into_iter()
- .filter_map(|content| match content {
- MessageContent::Text(text) => {
- let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
- text.trim_end().to_string()
- } else {
- text
- };
- if !text.is_empty() {
- Some(anthropic::RequestContent::Text {
- text,
- cache_control: None,
- })
- } else {
- None
- }
- }
- MessageContent::Thinking {
- text: thinking,
- signature,
- } => {
- if !thinking.is_empty() {
- Some(anthropic::RequestContent::Thinking {
- thinking,
- signature: signature.unwrap_or_default(),
- cache_control: None,
- })
- } else {
- None
- }
- }
- MessageContent::RedactedThinking(data) => {
- if !data.is_empty() {
- Some(anthropic::RequestContent::RedactedThinking { data })
- } else {
- None
- }
- }
- MessageContent::Image(image) => Some(anthropic::RequestContent::Image {
- source: anthropic::ImageSource {
- source_type: "base64".to_string(),
- media_type: "image/png".to_string(),
- data: image.source.to_string(),
- },
- cache_control: None,
- }),
- MessageContent::ToolUse(tool_use) => {
- Some(anthropic::RequestContent::ToolUse {
- id: tool_use.id.to_string(),
- name: tool_use.name.to_string(),
- input: tool_use.input,
- cache_control: None,
- })
- }
- MessageContent::ToolResult(tool_result) => {
- Some(anthropic::RequestContent::ToolResult {
- tool_use_id: tool_result.tool_use_id.to_string(),
- is_error: tool_result.is_error,
- content: match tool_result.content {
- LanguageModelToolResultContent::Text(text) => {
- ToolResultContent::Plain(text.to_string())
- }
- LanguageModelToolResultContent::Image(image) => {
- ToolResultContent::Multipart(vec![ToolResultPart::Image {
- source: anthropic::ImageSource {
- source_type: "base64".to_string(),
- media_type: "image/png".to_string(),
- data: image.source.to_string(),
- },
- }])
- }
- },
- cache_control: None,
- })
- }
- })
+ .filter_map(to_anthropic_content)
.collect();
let anthropic_role = match message.role {
Role::User => anthropic::Role::User,
Role::Assistant => anthropic::Role::Assistant,
Role::System => unreachable!("System role should never occur here"),
};
+ if anthropic_message_content.is_empty() {
+ continue;
+ }
+
if let Some(last_message) = new_messages.last_mut()
&& last_message.role == anthropic_role
{
@@ -624,87 +630,17 @@ pub fn into_anthropic(
let mut anthropic_message_content: Vec<anthropic::RequestContent> = message
.content
.into_iter()
- .filter_map(|content| match content {
- MessageContent::Text(text) => {
- let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
- text.trim_end().to_string()
- } else {
- text
- };
- if !text.is_empty() {
- Some(anthropic::RequestContent::Text {
- text,
- cache_control: None,
- })
- } else {
- None
- }
- }
- MessageContent::Thinking {
- text: thinking,
- signature,
- } => {
- if !thinking.is_empty() {
- Some(anthropic::RequestContent::Thinking {
- thinking,
- signature: signature.unwrap_or_default(),
- cache_control: None,
- })
- } else {
- None
- }
- }
- MessageContent::RedactedThinking(data) => {
- if !data.is_empty() {
- Some(anthropic::RequestContent::RedactedThinking { data })
- } else {
- None
- }
- }
- MessageContent::Image(image) => Some(anthropic::RequestContent::Image {
- source: anthropic::ImageSource {
- source_type: "base64".to_string(),
- media_type: "image/png".to_string(),
- data: image.source.to_string(),
- },
- cache_control: None,
- }),
- MessageContent::ToolUse(tool_use) => {
- Some(anthropic::RequestContent::ToolUse {
- id: tool_use.id.to_string(),
- name: tool_use.name.to_string(),
- input: tool_use.input,
- cache_control: None,
- })
- }
- MessageContent::ToolResult(tool_result) => {
- Some(anthropic::RequestContent::ToolResult {
- tool_use_id: tool_result.tool_use_id.to_string(),
- is_error: tool_result.is_error,
- content: match tool_result.content {
- LanguageModelToolResultContent::Text(text) => {
- ToolResultContent::Plain(text.to_string())
- }
- LanguageModelToolResultContent::Image(image) => {
- ToolResultContent::Multipart(vec![ToolResultPart::Image {
- source: anthropic::ImageSource {
- source_type: "base64".to_string(),
- media_type: "image/png".to_string(),
- data: image.source.to_string(),
- },
- }])
- }
- },
- cache_control: None,
- })
- }
- })
+ .filter_map(to_anthropic_content)
.collect();
let anthropic_role = match message.role {
Role::User => anthropic::Role::User,
Role::Assistant => anthropic::Role::Assistant,
Role::System => unreachable!("System role should never occur here"),
};
+ if anthropic_message_content.is_empty() {
+ continue;
+ }
+
if let Some(last_message) = new_messages.last_mut()
&& last_message.role == anthropic_role
{
@@ -1208,4 +1144,117 @@ mod tests {
}
));
}
+
+ fn request_with_assistant_content(
+ assistant_content: Vec<MessageContent>,
+ ) -> anthropic::Request {
+ let mut request = LanguageModelRequest {
+ messages: vec![LanguageModelRequestMessage {
+ role: Role::User,
+ content: vec![MessageContent::Text("Hello".to_string())],
+ cache: false,
+ reasoning_details: None,
+ }],
+ thinking_effort: None,
+ thread_id: None,
+ prompt_id: None,
+ intent: None,
+ stop: vec![],
+ temperature: None,
+ tools: vec![],
+ tool_choice: None,
+ thinking_allowed: true,
+ };
+ request.messages.push(LanguageModelRequestMessage {
+ role: Role::Assistant,
+ content: assistant_content,
+ cache: false,
+ reasoning_details: None,
+ });
+ into_anthropic(
+ request,
+ "claude-sonnet-4-5".to_string(),
+ 1.0,
+ 16000,
+ AnthropicModelMode::Thinking {
+ budget_tokens: Some(10000),
+ },
+ )
+ }
+
+ #[test]
+ fn test_unsigned_thinking_blocks_stripped() {
+ let result = request_with_assistant_content(vec![
+ MessageContent::Thinking {
+ text: "Cancelled mid-think, no signature".to_string(),
+ signature: None,
+ },
+ MessageContent::Text("Some response text".to_string()),
+ ]);
+
+ let assistant_message = result
+ .messages
+ .iter()
+ .find(|m| m.role == anthropic::Role::Assistant)
+ .expect("assistant message should still exist");
+
+ assert_eq!(
+ assistant_message.content.len(),
+ 1,
+ "Only the text content should remain; unsigned thinking block should be stripped"
+ );
+ assert!(matches!(
+ &assistant_message.content[0],
+ anthropic::RequestContent::Text { text, .. } if text == "Some response text"
+ ));
+ }
+
+ #[test]
+ fn test_signed_thinking_blocks_preserved() {
+ let result = request_with_assistant_content(vec![
+ MessageContent::Thinking {
+ text: "Completed thinking".to_string(),
+ signature: Some("valid-signature".to_string()),
+ },
+ MessageContent::Text("Response".to_string()),
+ ]);
+
+ let assistant_message = result
+ .messages
+ .iter()
+ .find(|m| m.role == anthropic::Role::Assistant)
+ .expect("assistant message should exist");
+
+ assert_eq!(
+ assistant_message.content.len(),
+ 2,
+ "Both the signed thinking block and text should be preserved"
+ );
+ assert!(matches!(
+ &assistant_message.content[0],
+ anthropic::RequestContent::Thinking { thinking, signature, .. }
+ if thinking == "Completed thinking" && signature == "valid-signature"
+ ));
+ }
+
+ #[test]
+ fn test_only_unsigned_thinking_block_omits_entire_message() {
+ let result = request_with_assistant_content(vec![MessageContent::Thinking {
+ text: "Cancelled before any text or signature".to_string(),
+ signature: None,
+ }]);
+
+ let assistant_messages: Vec<_> = result
+ .messages
+ .iter()
+ .filter(|m| m.role == anthropic::Role::Assistant)
+ .collect();
+
+ assert_eq!(
+ assistant_messages.len(),
+ 0,
+ "An assistant message whose only content was an unsigned thinking block \
+ should be omitted entirely"
+ );
+ }
}
@@ -770,6 +770,12 @@ pub fn into_bedrock(
// And the AWS API demands that you strip them
return None;
}
+ if signature.is_none() {
+ // Thinking blocks without a signature are invalid
+ // (e.g. from cancellation mid-think) and must be
+ // stripped to avoid API errors.
+ return None;
+ }
let thinking = BedrockThinkingTextBlock::builder()
.text(text)
.set_signature(signature)
@@ -850,6 +856,10 @@ pub fn into_bedrock(
Role::Assistant => bedrock::BedrockRole::Assistant,
Role::System => unreachable!("System role should never occur here"),
};
+ if bedrock_message_content.is_empty() {
+ continue;
+ }
+
if let Some(last_message) = new_messages.last_mut()
&& last_message.role == bedrock_role
{