Bump version to 1.6.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4ba9706e35
commit
59cfd0e2f5
@@ -7,13 +7,23 @@ use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
|
||||
|
||||
use crate::settings::ai::BedrockAuthMethod;
|
||||
|
||||
use super::external_config::ExternalBedrockConfig;
|
||||
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
|
||||
use super::diagnostic::BedrockDiagnosticLogger;
|
||||
use super::external_config::ExternalBedrockConfig;
|
||||
use super::models::apply_cross_region_prefix;
|
||||
use super::response_translator::bedrock_stream_to_response_events;
|
||||
use crate::ai::agent::api::ResponseStream;
|
||||
|
||||
fn strip_context_marker(model_id: &str) -> String {
|
||||
if let Some(base) = model_id.strip_suffix("[1m]") {
|
||||
base.to_string()
|
||||
} else if let Some(base) = model_id.strip_suffix("[1M]") {
|
||||
base.to_string()
|
||||
} else {
|
||||
model_id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BedrockClient {
|
||||
runtime_client: BedrockRuntimeClient,
|
||||
region: String,
|
||||
@@ -141,12 +151,13 @@ impl BedrockClient {
|
||||
user_query: Option<String>,
|
||||
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
|
||||
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||
is_summarization: bool,
|
||||
tool_result_archive: Vec<ConversationMessage>,
|
||||
) -> Result<ResponseStream, BedrockError> {
|
||||
let base_model_id = strip_context_marker(model_id);
|
||||
let effective_model_id = if cross_region_inference {
|
||||
apply_cross_region_prefix(model_id, &self.region)
|
||||
apply_cross_region_prefix(&base_model_id, &self.region)
|
||||
} else {
|
||||
model_id.to_string()
|
||||
base_model_id
|
||||
};
|
||||
|
||||
let external_config = ExternalBedrockConfig::load();
|
||||
@@ -238,9 +249,74 @@ impl BedrockClient {
|
||||
user_query,
|
||||
diagnostic_logger,
|
||||
messages_sent,
|
||||
effective_model_id,
|
||||
is_summarization,
|
||||
model_id.to_string(),
|
||||
tool_result_archive,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Performs a non-streaming converse call and collects the full response text.
|
||||
/// Used for background progressive summarization where we don't need streaming UI.
|
||||
/// Returns (response_text, input_tokens, output_tokens).
|
||||
pub async fn converse_collect(
|
||||
&self,
|
||||
model_id: &str,
|
||||
messages: Vec<ConversationMessage>,
|
||||
system_prompt: Option<String>,
|
||||
max_tokens: i32,
|
||||
cross_region_inference: bool,
|
||||
) -> Result<(String, u32, u32), BedrockError> {
|
||||
let base_model_id = strip_context_marker(model_id);
|
||||
let effective_model_id = if cross_region_inference {
|
||||
apply_cross_region_prefix(&base_model_id, &self.region)
|
||||
} else {
|
||||
base_model_id
|
||||
};
|
||||
|
||||
let external_config = ExternalBedrockConfig::load();
|
||||
let caching_config = CachingConfig::from_external_config(&external_config);
|
||||
|
||||
let converted = build_converse_request(
|
||||
messages,
|
||||
system_prompt,
|
||||
None,
|
||||
vec![],
|
||||
max_tokens,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
caching_config,
|
||||
);
|
||||
|
||||
let request = self
|
||||
.runtime_client
|
||||
.converse()
|
||||
.model_id(&effective_model_id)
|
||||
.set_system(Some(converted.system))
|
||||
.set_messages(Some(converted.messages))
|
||||
.inference_config(converted.inference_config);
|
||||
|
||||
let output = request.send().await.map_err(|e| {
|
||||
let msg = format!("{e}");
|
||||
log::error!("[bedrock] converse_collect error: {msg}");
|
||||
BedrockError::ApiError(msg)
|
||||
})?;
|
||||
|
||||
let mut response_text = String::new();
|
||||
if let Some(output_msg) = output.output() {
|
||||
if let aws_sdk_bedrockruntime::types::ConverseOutput::Message(msg) = output_msg {
|
||||
for block in msg.content() {
|
||||
if let aws_sdk_bedrockruntime::types::ContentBlock::Text(text) = block {
|
||||
response_text.push_str(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (input_tokens, output_tokens) = output
|
||||
.usage()
|
||||
.map(|u| (u.input_tokens() as u32, u.output_tokens() as u32))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
Ok((response_text, input_tokens, output_tokens))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use std::collections::HashMap;
|
||||
|
||||
use aws_sdk_bedrockruntime::types::{
|
||||
CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole,
|
||||
InferenceConfiguration, Message as BedrockMessage, SystemContentBlock, Tool,
|
||||
ToolConfiguration, ToolInputSchema, ToolResultBlock, ToolResultContentBlock,
|
||||
ToolResultStatus, ToolSpecification, ToolUseBlock,
|
||||
InferenceConfiguration, Message as BedrockMessage, SystemContentBlock, Tool, ToolConfiguration,
|
||||
ToolInputSchema, ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification,
|
||||
ToolUseBlock,
|
||||
};
|
||||
use aws_smithy_types::Document;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -141,7 +141,10 @@ fn json_to_document(value: JsonValue) -> Document {
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_messages(messages: Vec<ConversationMessage>, caching_config: &CachingConfig) -> Vec<BedrockMessage> {
|
||||
fn convert_messages(
|
||||
messages: Vec<ConversationMessage>,
|
||||
caching_config: &CachingConfig,
|
||||
) -> Vec<BedrockMessage> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
@@ -300,7 +303,7 @@ fn coalesce_consecutive_roles(messages: Vec<BedrockMessage>) -> Vec<BedrockMessa
|
||||
|
||||
fn convert_system_prompt(
|
||||
system_prompt: Option<String>,
|
||||
compact_summary: Option<String>,
|
||||
_compact_summary: Option<String>,
|
||||
caching_config: &CachingConfig,
|
||||
) -> Vec<SystemContentBlock> {
|
||||
let mut blocks = Vec::new();
|
||||
@@ -311,11 +314,9 @@ fn convert_system_prompt(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(summary) = compact_summary {
|
||||
blocks.push(SystemContentBlock::Text(format!(
|
||||
"<conversation-summary>\n{summary}\n</conversation-summary>"
|
||||
)));
|
||||
}
|
||||
// Progressive summary is now prepended to the messages array instead of
|
||||
// being injected here. The compact_summary parameter is kept for API compat
|
||||
// but ignored.
|
||||
|
||||
if !blocks.is_empty() && caching_config.enabled {
|
||||
let mut builder = CachePointBlock::builder().r#type(CachePointType::Default);
|
||||
@@ -351,7 +352,10 @@ fn build_inference_config(
|
||||
builder.build()
|
||||
}
|
||||
|
||||
fn build_tool_config(tools: Vec<ToolDefinition>, caching_config: &CachingConfig) -> Option<ToolConfiguration> {
|
||||
fn build_tool_config(
|
||||
tools: Vec<ToolDefinition>,
|
||||
caching_config: &CachingConfig,
|
||||
) -> Option<ToolConfiguration> {
|
||||
if tools.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -705,6 +705,31 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
|
||||
"required": ["prompt", "label"]
|
||||
}),
|
||||
},
|
||||
"recall_tool_history" => ToolDefinition {
|
||||
name: "recall_tool_history".to_string(),
|
||||
description: "Retrieve the full output of a past tool execution from conversation history. Use when you need details from a tool call that is no longer in your current context window.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search_query": {
|
||||
"type": "string",
|
||||
"description": "Search text to match against tool names, inputs, or outputs"
|
||||
},
|
||||
"tool_name": {
|
||||
"type": "string",
|
||||
"description": "Filter by tool name (run_shell_command, read_files, grep, file_glob, apply_file_diffs)"
|
||||
},
|
||||
"tool_use_id": {
|
||||
"type": "string",
|
||||
"description": "Exact tool_use_id to look up (most precise)"
|
||||
},
|
||||
"offset_from_end": {
|
||||
"type": "integer",
|
||||
"description": "How many matching tool calls back from most recent (0 = most recent match)"
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
_ => ToolDefinition {
|
||||
name: name.to_string(),
|
||||
description: format!("Tool: {}", name),
|
||||
@@ -724,6 +749,7 @@ fn default_tool_definitions() -> Vec<ToolDefinition> {
|
||||
tool_definition_for_name("grep"),
|
||||
tool_definition_for_name("file_glob"),
|
||||
tool_definition_for_name("suggest_next_prompt"),
|
||||
tool_definition_for_name("recall_tool_history"),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,17 @@ fn test_text_message_converts_to_single_block() {
|
||||
content: MessageContent::Text("Hello".to_string()),
|
||||
}];
|
||||
|
||||
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
messages,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
assert_eq!(result.messages[0].role(), &ConversationRole::User);
|
||||
@@ -29,7 +39,17 @@ fn test_tool_use_produces_valid_json_input() {
|
||||
},
|
||||
}];
|
||||
|
||||
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
messages,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
assert_eq!(result.messages[0].role(), &ConversationRole::Assistant);
|
||||
@@ -53,7 +73,17 @@ fn test_tool_result_with_matching_id() {
|
||||
},
|
||||
}];
|
||||
|
||||
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
messages,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
match &result.messages[0].content()[0] {
|
||||
@@ -75,7 +105,17 @@ fn test_tool_result_error_status() {
|
||||
},
|
||||
}];
|
||||
|
||||
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
messages,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
match &result.messages[0].content()[0] {
|
||||
ContentBlock::ToolResult(block) => {
|
||||
@@ -101,7 +141,17 @@ fn test_consecutive_same_role_messages_coalesced() {
|
||||
},
|
||||
];
|
||||
|
||||
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
messages,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
assert_eq!(result.messages.len(), 1);
|
||||
assert_eq!(result.messages[0].content().len(), 2);
|
||||
@@ -126,7 +176,17 @@ fn test_alternating_roles_not_coalesced() {
|
||||
},
|
||||
];
|
||||
|
||||
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
messages,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
assert_eq!(result.messages.len(), 3);
|
||||
assert_eq!(result.messages[0].role(), &ConversationRole::User);
|
||||
@@ -160,17 +220,46 @@ fn test_system_prompt_separated_from_messages() {
|
||||
|
||||
#[test]
|
||||
fn test_empty_system_prompt_produces_empty_vec() {
|
||||
let result =
|
||||
build_converse_request(vec![], Some("".to_string()), None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
vec![],
|
||||
Some("".to_string()),
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
assert!(result.system.is_empty());
|
||||
|
||||
let result2 = build_converse_request(vec![], None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result2 = build_converse_request(
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
assert!(result2.system.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tools_produce_none_config() {
|
||||
let result = build_converse_request(vec![], None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
assert!(result.tool_config.is_none());
|
||||
}
|
||||
|
||||
@@ -188,7 +277,17 @@ fn test_tool_definitions_produce_tool_config() {
|
||||
}),
|
||||
}];
|
||||
|
||||
let result = build_converse_request(vec![], None, None, tools, 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
tools,
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
assert!(result.tool_config.is_some());
|
||||
let config = result.tool_config.unwrap();
|
||||
@@ -197,7 +296,17 @@ fn test_tool_definitions_produce_tool_config() {
|
||||
|
||||
#[test]
|
||||
fn test_inference_config_max_tokens_only() {
|
||||
let result = build_converse_request(vec![], None, None, vec![], 8192, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
vec![],
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
8192,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
assert_eq!(result.inference_config.max_tokens(), Some(8192));
|
||||
assert_eq!(result.inference_config.temperature(), None);
|
||||
assert_eq!(result.inference_config.top_p(), None);
|
||||
@@ -237,7 +346,17 @@ fn test_multipart_content_produces_multiple_blocks() {
|
||||
]),
|
||||
}];
|
||||
|
||||
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
messages,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
assert_eq!(result.messages[0].content().len(), 2);
|
||||
assert!(matches!(
|
||||
@@ -275,7 +394,17 @@ fn test_tool_result_after_tool_use_coalesced_into_user_message() {
|
||||
},
|
||||
];
|
||||
|
||||
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
|
||||
let result = build_converse_request(
|
||||
messages,
|
||||
None,
|
||||
None,
|
||||
vec![],
|
||||
4096,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
CachingConfig::default(),
|
||||
);
|
||||
|
||||
assert_eq!(result.messages.len(), 3);
|
||||
assert_eq!(result.messages[0].role(), &ConversationRole::User);
|
||||
@@ -392,7 +521,10 @@ fn test_caching_enabled_has_cache_points() {
|
||||
.content()
|
||||
.iter()
|
||||
.any(|c| matches!(c, ContentBlock::CachePoint(_)));
|
||||
assert!(has_cache_point, "Second-to-last message should have cache point");
|
||||
assert!(
|
||||
has_cache_point,
|
||||
"Second-to-last message should have cache point"
|
||||
);
|
||||
|
||||
// Check that cache point exists in system
|
||||
use aws_sdk_bedrockruntime::types::SystemContentBlock;
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_sdk_bedrock::types::InferenceProfileType;
|
||||
use aws_sdk_bedrock::Client as BedrockControlClient;
|
||||
use aws_sdk_bedrockruntime::config::Region;
|
||||
|
||||
use crate::settings::ai::{BedrockAuthMethod, BedrockModelConfig};
|
||||
|
||||
use super::client::BedrockClientConfig;
|
||||
|
||||
pub fn list_aws_profiles() -> Vec<String> {
|
||||
let mut profiles = BTreeSet::new();
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
parse_config_file(home.join(".aws").join("config"), &mut profiles);
|
||||
parse_credentials_file(home.join(".aws").join("credentials"), &mut profiles);
|
||||
}
|
||||
|
||||
profiles.into_iter().collect()
|
||||
}
|
||||
|
||||
fn parse_config_file(path: PathBuf, profiles: &mut BTreeSet<String>) {
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
let section = &trimmed[1..trimmed.len() - 1];
|
||||
if section == "default" {
|
||||
profiles.insert("default".to_string());
|
||||
} else if let Some(name) = section.strip_prefix("profile ") {
|
||||
profiles.insert(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_credentials_file(path: PathBuf, profiles: &mut BTreeSet<String>) {
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
let name = &trimmed[1..trimmed.len() - 1];
|
||||
profiles.insert(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn discover_inference_profiles(
|
||||
config: &BedrockClientConfig,
|
||||
) -> Result<Vec<BedrockModelConfig>> {
|
||||
let aws_config = build_aws_config(config).await;
|
||||
let client = BedrockControlClient::new(&aws_config);
|
||||
|
||||
let mut models = Vec::new();
|
||||
|
||||
fetch_profiles_by_type(&client, InferenceProfileType::SystemDefined, &mut models).await?;
|
||||
fetch_profiles_by_type(&client, InferenceProfileType::Application, &mut models).await?;
|
||||
|
||||
models.sort_by(|a, b| a.display_name.cmp(&b.display_name));
|
||||
models.dedup_by(|a, b| a.model_id == b.model_id);
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
async fn fetch_profiles_by_type(
|
||||
client: &BedrockControlClient,
|
||||
profile_type: InferenceProfileType,
|
||||
models: &mut Vec<BedrockModelConfig>,
|
||||
) -> Result<()> {
|
||||
let mut next_token: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let mut req = client
|
||||
.list_inference_profiles()
|
||||
.type_equals(profile_type.clone())
|
||||
.max_results(100);
|
||||
if let Some(token) = next_token.take() {
|
||||
req = req.next_token(token);
|
||||
}
|
||||
|
||||
let resp = req.send().await?;
|
||||
|
||||
for summary in resp.inference_profile_summaries() {
|
||||
let profile_id = summary.inference_profile_id();
|
||||
let profile_name = summary.inference_profile_name();
|
||||
let profile_arn = summary.inference_profile_arn();
|
||||
|
||||
let model_id = if profile_arn.contains(":application-inference-profile/") {
|
||||
profile_arn.to_string()
|
||||
} else {
|
||||
profile_id.to_string()
|
||||
};
|
||||
|
||||
if should_skip_model(profile_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
models.push(BedrockModelConfig {
|
||||
model_id,
|
||||
display_name: profile_name.to_string(),
|
||||
vision_supported: true,
|
||||
});
|
||||
}
|
||||
|
||||
next_token = resp.next_token().map(|s| s.to_string());
|
||||
if next_token.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_skip_model(name: &str) -> bool {
|
||||
let lower = name.to_lowercase();
|
||||
lower.contains("embed")
|
||||
|| lower.contains("stable image")
|
||||
|| lower.contains("stable-image")
|
||||
|| lower.contains("upscale")
|
||||
|| lower.contains("outpaint")
|
||||
|| lower.contains("inpaint")
|
||||
|| lower.contains("recolor")
|
||||
|| lower.contains("erase")
|
||||
|| lower.contains("style transfer")
|
||||
|| lower.contains("style guide")
|
||||
|| lower.contains("remove background")
|
||||
|| lower.contains("search and replace")
|
||||
|| lower.contains("control sketch")
|
||||
|| lower.contains("control structure")
|
||||
}
|
||||
|
||||
async fn build_aws_config(config: &BedrockClientConfig) -> aws_config::SdkConfig {
|
||||
match config.auth_method {
|
||||
BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => {
|
||||
let mut loader =
|
||||
aws_config::defaults(BehaviorVersion::latest()).profile_name(&config.profile);
|
||||
|
||||
if !config.region.is_empty() {
|
||||
loader = loader.region(Region::new(config.region.clone()));
|
||||
}
|
||||
|
||||
loader.load().await
|
||||
}
|
||||
BedrockAuthMethod::StaticKeys => {
|
||||
let creds = aws_credential_types::Credentials::new(
|
||||
&config.access_key_id,
|
||||
&config.secret_access_key,
|
||||
None,
|
||||
None,
|
||||
"warp-bedrock-discovery",
|
||||
);
|
||||
|
||||
let mut loader =
|
||||
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(creds);
|
||||
|
||||
if !config.region.is_empty() {
|
||||
loader = loader.region(Region::new(config.region.clone()));
|
||||
} else {
|
||||
loader = loader.region(Region::new("us-east-1".to_string()));
|
||||
}
|
||||
|
||||
loader.load().await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -558,7 +558,7 @@ impl AgentSimulation {
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.expect("converse_stream should succeed");
|
||||
@@ -1147,7 +1147,7 @@ async fn test_reasoning_model_produces_substantial_output() {
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.expect("converse_stream should succeed");
|
||||
@@ -1272,7 +1272,7 @@ async fn test_event_sequence_matches_controller_expectations() {
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.expect("should connect");
|
||||
@@ -1389,7 +1389,7 @@ async fn test_followup_turn_does_not_send_create_task() {
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.expect("should connect");
|
||||
@@ -1475,7 +1475,7 @@ async fn run_slash_command_test(
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.expect("stream should connect");
|
||||
@@ -1705,7 +1705,7 @@ async fn test_slash_resume_conversation() {
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.expect("stream should connect");
|
||||
@@ -1819,7 +1819,7 @@ async fn test_empty_messages_safety_check() {
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.expect("safety fallback message should work");
|
||||
@@ -1982,7 +1982,7 @@ async fn test_full_proto_round_trip_with_tool_history() {
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -22,12 +22,8 @@ impl ExternalBedrockConfig {
|
||||
|
||||
// Claude Code takes priority over OpenCode since it has richer model mappings
|
||||
Self {
|
||||
profile: claude_config
|
||||
.profile
|
||||
.or(opencode_config.profile),
|
||||
region: claude_config
|
||||
.region
|
||||
.or(opencode_config.region),
|
||||
profile: claude_config.profile.or(opencode_config.profile),
|
||||
region: claude_config.region.or(opencode_config.region),
|
||||
models: if claude_config.models.is_empty() {
|
||||
opencode_config.models
|
||||
} else {
|
||||
@@ -73,10 +69,12 @@ fn parse_claude_code_config(path: PathBuf) -> ExternalBedrockConfig {
|
||||
|
||||
let env = match json.get("env").and_then(|v| v.as_object()) {
|
||||
Some(e) => e,
|
||||
None => return ExternalBedrockConfig {
|
||||
auth_refresh_command,
|
||||
..Default::default()
|
||||
},
|
||||
None => {
|
||||
return ExternalBedrockConfig {
|
||||
auth_refresh_command,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let profile = env
|
||||
@@ -146,6 +144,7 @@ fn parse_claude_code_model_map(
|
||||
model_id: arn,
|
||||
display_name,
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -175,10 +174,7 @@ fn derive_display_name(model_id: &str) -> String {
|
||||
|
||||
fn prettify_model_slug(slug: &str) -> String {
|
||||
// Remove version suffixes like "-v1:0", "-v1", "-v2:0"
|
||||
let slug = slug
|
||||
.split("-v")
|
||||
.next()
|
||||
.unwrap_or(slug);
|
||||
let slug = slug.split("-v").next().unwrap_or(slug);
|
||||
|
||||
// Remove date suffixes like "-20250514" or "-20251001"
|
||||
let parts: Vec<&str> = slug.split('-').collect();
|
||||
@@ -275,6 +271,53 @@ fn parse_opencode_config(path: PathBuf) -> ExternalBedrockConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_aws_profiles() -> Vec<String> {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
let mut profiles = BTreeSet::new();
|
||||
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
parse_aws_config_file(home.join(".aws").join("config"), &mut profiles);
|
||||
parse_aws_credentials_file(home.join(".aws").join("credentials"), &mut profiles);
|
||||
}
|
||||
|
||||
profiles.into_iter().collect()
|
||||
}
|
||||
|
||||
fn parse_aws_config_file(path: PathBuf, profiles: &mut std::collections::BTreeSet<String>) {
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
let section = &trimmed[1..trimmed.len() - 1];
|
||||
if section == "default" {
|
||||
profiles.insert("default".to_string());
|
||||
} else if let Some(name) = section.strip_prefix("profile ") {
|
||||
profiles.insert(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_aws_credentials_file(path: PathBuf, profiles: &mut std::collections::BTreeSet<String>) {
|
||||
let contents = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for line in contents.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
let name = &trimmed[1..trimmed.len() - 1];
|
||||
profiles.insert(name.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "external_config_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -156,18 +156,18 @@ fn test_claude_code_takes_priority_over_opencode() {
|
||||
let claude = ExternalBedrockConfig {
|
||||
profile: Some("claude-profile".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
models: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
let opencode = ExternalBedrockConfig {
|
||||
profile: Some("opencode-profile".to_string()),
|
||||
region: Some("eu-west-1".to_string()),
|
||||
models: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let merged = ExternalBedrockConfig {
|
||||
profile: claude.profile.or(opencode.profile),
|
||||
region: claude.region.or(opencode.region),
|
||||
models: Vec::new(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(merged.profile, Some("claude-profile".to_string()));
|
||||
|
||||
@@ -67,7 +67,7 @@ async fn collect_stream_output(
|
||||
None,
|
||||
None,
|
||||
Arc::new(Mutex::new(Vec::new())),
|
||||
false,
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.expect("converse_stream should succeed");
|
||||
@@ -624,7 +624,11 @@ async fn test_all_tools_visible_to_model() {
|
||||
let tools = super::request_translator::default_tool_definitions();
|
||||
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
|
||||
|
||||
println!("[test] Sending {} tools to Bedrock: {:?}", tools.len(), tool_names);
|
||||
println!(
|
||||
"[test] Sending {} tools to Bedrock: {:?}",
|
||||
tools.len(),
|
||||
tool_names
|
||||
);
|
||||
|
||||
let messages = vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
@@ -656,7 +660,10 @@ async fn test_all_tools_visible_to_model() {
|
||||
}
|
||||
|
||||
if !missing_tools.is_empty() {
|
||||
println!("[test] WARNING: Model did not mention these tools: {:?}", missing_tools);
|
||||
println!(
|
||||
"[test] WARNING: Model did not mention these tools: {:?}",
|
||||
missing_tools
|
||||
);
|
||||
}
|
||||
|
||||
let expected_core_tools = [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
pub mod client;
|
||||
pub mod convert;
|
||||
pub mod diagnostic;
|
||||
pub mod discovery;
|
||||
pub mod external_config;
|
||||
pub mod models;
|
||||
pub mod request_translator;
|
||||
|
||||
@@ -6,53 +6,105 @@ pub struct DefaultModel {
|
||||
pub model_id: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub vision_supported: bool,
|
||||
pub context_size: u32,
|
||||
}
|
||||
|
||||
pub const DEFAULT_BEDROCK_MODELS: &[DefaultModel] = &[
|
||||
DefaultModel {
|
||||
model_id: "anthropic.claude-opus-4-6",
|
||||
display_name: "Claude Opus 4.6",
|
||||
model_id: "us.anthropic.claude-opus-4-6-v1[1m]",
|
||||
display_name: "Claude Opus 4.6 (1M)",
|
||||
vision_supported: true,
|
||||
context_size: 1_000_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "anthropic.claude-opus-4-7",
|
||||
display_name: "Claude Opus 4.7",
|
||||
model_id: "us.anthropic.claude-sonnet-4-6[1m]",
|
||||
display_name: "Claude Sonnet 4.6 (1M)",
|
||||
vision_supported: true,
|
||||
context_size: 1_000_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "anthropic.claude-sonnet-4-6",
|
||||
display_name: "Claude Sonnet 4.6",
|
||||
model_id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
display_name: "Claude Sonnet 4.5",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
model_id: "us.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
display_name: "Claude Sonnet 4",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
model_id: "us.anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
display_name: "Claude 3 Sonnet",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "global.anthropic.claude-sonnet-4-6",
|
||||
display_name: "Claude Sonnet 4.6 (Global)",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
display_name: "Claude Sonnet 4.5 (Global)",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "global.anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
display_name: "Claude Sonnet 4 (Global)",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "us.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
display_name: "Claude Opus 4.5",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "us.anthropic.claude-opus-4-1-20250805-v1:0",
|
||||
display_name: "Claude Opus 4.1",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "global.anthropic.claude-opus-4-6-v1",
|
||||
display_name: "Claude Opus 4.6 (Global)",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "global.anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
display_name: "Claude Opus 4.5 (Global)",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
display_name: "Claude Haiku 4.5",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "amazon.nova-pro-v1:0",
|
||||
display_name: "Amazon Nova Pro",
|
||||
model_id: "us.anthropic.claude-3-haiku-20240307-v1:0",
|
||||
display_name: "Claude 3 Haiku",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "amazon.nova-lite-v1:0",
|
||||
display_name: "Amazon Nova Lite",
|
||||
model_id: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
|
||||
display_name: "Claude 3.5 Haiku",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "amazon.nova-micro-v1:0",
|
||||
display_name: "Amazon Nova Micro",
|
||||
vision_supported: false,
|
||||
},
|
||||
DefaultModel {
|
||||
model_id: "deepseek.r1-v1:0",
|
||||
display_name: "DeepSeek R1",
|
||||
vision_supported: false,
|
||||
model_id: "global.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
display_name: "Claude Haiku 4.5 (Global)",
|
||||
vision_supported: true,
|
||||
context_size: 200_000,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -76,6 +128,7 @@ pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockMo
|
||||
model_id: m.model_id.to_string(),
|
||||
display_name: m.display_name.to_string(),
|
||||
vision_supported: m.vision_supported,
|
||||
context_size: m.context_size,
|
||||
})
|
||||
.collect();
|
||||
for default in defaults {
|
||||
@@ -92,6 +145,7 @@ pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockMo
|
||||
model_id: m.model_id.to_string(),
|
||||
display_name: m.display_name.to_string(),
|
||||
vision_supported: m.vision_supported,
|
||||
context_size: m.context_size,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ fn test_cross_region_prefix_unknown_region() {
|
||||
fn test_get_effective_models_empty_returns_defaults() {
|
||||
let models = get_effective_models(&[]);
|
||||
assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len());
|
||||
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-6");
|
||||
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-6[1m]");
|
||||
assert_eq!(models[0].display_name, "Claude Opus 4.6");
|
||||
}
|
||||
|
||||
|
||||
@@ -408,12 +408,10 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
|
||||
timestamp: None,
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(api::message::Message::UserQuery(
|
||||
api::message::UserQuery {
|
||||
query: prompt,
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
|
||||
query: prompt,
|
||||
..Default::default()
|
||||
})),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
@@ -440,8 +438,13 @@ pub fn sanitize_messages_for_bedrock(messages: &mut Vec<ConversationMessage>) {
|
||||
/// If the last message is an assistant message (e.g. after compaction),
|
||||
/// append a continuation prompt.
|
||||
fn ensure_ends_with_user_message(messages: &mut Vec<ConversationMessage>) {
|
||||
if messages.last().is_some_and(|m| m.role == MessageRole::Assistant) {
|
||||
log::info!("[bedrock] Appending continuation prompt (conversation ended with assistant message)");
|
||||
if messages
|
||||
.last()
|
||||
.is_some_and(|m| m.role == MessageRole::Assistant)
|
||||
{
|
||||
log::info!(
|
||||
"[bedrock] Appending continuation prompt (conversation ended with assistant message)"
|
||||
);
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text("Continue.".to_string()),
|
||||
@@ -514,12 +517,24 @@ fn strip_tool_result_parts(content: &mut MessageContent) {
|
||||
let part = parts.remove(0);
|
||||
*content = match part {
|
||||
ContentPart::Text(t) => MessageContent::Text(t),
|
||||
ContentPart::ToolUse { tool_use_id, name, input } => {
|
||||
MessageContent::ToolUse { tool_use_id, name, input }
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
|
||||
MessageContent::ToolResult { tool_use_id, content: c, is_error }
|
||||
}
|
||||
ContentPart::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
} => MessageContent::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
},
|
||||
ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
content: c,
|
||||
is_error,
|
||||
} => MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
content: c,
|
||||
is_error,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -544,12 +559,24 @@ fn strip_orphaned_tool_result_parts(
|
||||
let part = parts.remove(0);
|
||||
*content = match part {
|
||||
ContentPart::Text(t) => MessageContent::Text(t),
|
||||
ContentPart::ToolUse { tool_use_id, name, input } => {
|
||||
MessageContent::ToolUse { tool_use_id, name, input }
|
||||
}
|
||||
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
|
||||
MessageContent::ToolResult { tool_use_id, content: c, is_error }
|
||||
}
|
||||
ContentPart::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
} => MessageContent::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
},
|
||||
ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
content: c,
|
||||
is_error,
|
||||
} => MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
content: c,
|
||||
is_error,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -565,7 +592,10 @@ fn is_empty_content(content: &MessageContent) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_tool_use_ids_into(content: &MessageContent, ids: &mut std::collections::HashSet<String>) {
|
||||
fn collect_tool_use_ids_into(
|
||||
content: &MessageContent,
|
||||
ids: &mut std::collections::HashSet<String>,
|
||||
) {
|
||||
match content {
|
||||
MessageContent::ToolUse { tool_use_id, .. } => {
|
||||
ids.insert(tool_use_id.clone());
|
||||
@@ -895,7 +925,9 @@ pub fn extract_system_prompt(request: &api::Request) -> Option<String> {
|
||||
prompt.push_str("- For tasks that require interacting with external services, web UIs, or capabilities not covered by your filesystem and shell tools, use your MCP tools.\n");
|
||||
prompt.push_str("- For complex multi-step tasks where a single script would replace many tool calls, write code (Python, Node, bash) via `run_shell_command` to reduce round-trips. But never use scripts for simple operations that a single command handles.\n\n");
|
||||
prompt.push_str("**Critical rules:**\n");
|
||||
prompt.push_str("- Use ONLY the tools in your tool configuration. Never invent or guess tool names.\n");
|
||||
prompt.push_str(
|
||||
"- Use ONLY the tools in your tool configuration. Never invent or guess tool names.\n",
|
||||
);
|
||||
prompt.push_str("- ALWAYS pass `--no-pager` (or equivalent) flags to CLI tools like git, less, man, etc. Tools that lock stdin will freeze the session.\n");
|
||||
prompt.push_str("- Output text directly in your response instead of using `echo` — echo requires user approval and adds unnecessary friction.\n");
|
||||
prompt.push_str("- Use absolute paths based on the working directory shown above.\n");
|
||||
@@ -1442,6 +1474,7 @@ fn is_server_result_error(text: &str) -> bool {
|
||||
|| lower.starts_with("mcp tool error:")
|
||||
|| lower.starts_with("search codebase error:")
|
||||
|| lower.starts_with("failed to read files")
|
||||
|| lower.starts_with("user cancelled")
|
||||
|| (lower.starts_with("exit code:") && !lower.starts_with("exit code: 0"))
|
||||
}
|
||||
|
||||
@@ -1533,7 +1566,6 @@ fn convert_proto_message_for_test(msg: &api::Message) -> Option<ConversationMess
|
||||
convert_proto_message(msg)
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "request_translator_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
||||
use super::sanitize_messages_for_bedrock;
|
||||
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_messages_prepends_synthetic_tool_result_before_existing_user_text() {
|
||||
|
||||
@@ -65,7 +65,7 @@ pub fn bedrock_stream_to_response_events(
|
||||
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
|
||||
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||
model_id: String,
|
||||
is_summarization: bool,
|
||||
tool_result_archive: Vec<ConversationMessage>,
|
||||
) -> BoxStream<'static, Event> {
|
||||
let request_id = Uuid::new_v4().to_string();
|
||||
let conversation_id = Uuid::new_v4().to_string();
|
||||
@@ -224,6 +224,50 @@ pub fn bedrock_stream_to_response_events(
|
||||
current_tool_use_id.clear();
|
||||
current_tool_name.clear();
|
||||
current_tool_input_json.clear();
|
||||
} else if current_tool_name == "recall_tool_history" {
|
||||
// Handle recall_tool_history locally by searching
|
||||
// the conversation messages that were sent.
|
||||
log::info!("[bedrock] Handling recall_tool_history locally");
|
||||
let input_json: serde_json::Value = serde_json::from_str(¤t_tool_input_json)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
let search_query = input_json.get("search_query")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let tool_name_filter = input_json.get("tool_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let tool_use_id = input_json.get("tool_use_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let offset = input_json.get("offset_from_end")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as usize;
|
||||
|
||||
let recall_result = match messages_sent.lock() {
|
||||
Ok(sent) => recall_from_history(
|
||||
&sent,
|
||||
&tool_result_archive,
|
||||
search_query,
|
||||
tool_name_filter,
|
||||
tool_use_id,
|
||||
offset,
|
||||
),
|
||||
Err(_) => "Error: could not access conversation history.".to_string(),
|
||||
};
|
||||
|
||||
history_tool_calls.push(ContentPart::ToolUse {
|
||||
tool_use_id: current_tool_use_id.clone(),
|
||||
name: current_tool_name.clone(),
|
||||
input: input_json,
|
||||
});
|
||||
synthetic_tool_results.push(ContentPart::ToolResult {
|
||||
tool_use_id: current_tool_use_id.clone(),
|
||||
content: recall_result,
|
||||
is_error: false,
|
||||
});
|
||||
current_tool_use_id.clear();
|
||||
current_tool_name.clear();
|
||||
current_tool_input_json.clear();
|
||||
} else if !is_known_tool(¤t_tool_name) {
|
||||
// Unknown/hallucinated tool: record it in history
|
||||
// with a paired error result so the conversation
|
||||
@@ -233,6 +277,10 @@ pub fn bedrock_stream_to_response_events(
|
||||
"[bedrock] Model called unknown tool '{}' (id={}), synthesizing error result",
|
||||
current_tool_name, current_tool_use_id
|
||||
);
|
||||
let error_text = format!(
|
||||
"Error: '{}' is not a valid tool. Available tools are: run_shell_command, read_files, apply_file_diffs, grep, file_glob. Please use one of these tools instead.",
|
||||
current_tool_name
|
||||
);
|
||||
let input_json: serde_json::Value = serde_json::from_str(¤t_tool_input_json)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
history_tool_calls.push(ContentPart::ToolUse {
|
||||
@@ -245,12 +293,20 @@ pub fn bedrock_stream_to_response_events(
|
||||
// fix it up later (and so the executor doesn't hang).
|
||||
synthetic_tool_results.push(ContentPart::ToolResult {
|
||||
tool_use_id: current_tool_use_id.clone(),
|
||||
content: format!(
|
||||
"Error: '{}' is not a valid tool. Available tools are: run_shell_command, read_files, apply_file_diffs, grep, file_glob. Please use one of these tools instead.",
|
||||
current_tool_name
|
||||
),
|
||||
content: error_text.clone(),
|
||||
is_error: true,
|
||||
});
|
||||
// Emit to the UI so the user sees the failed tool call
|
||||
let error_msg_id = Uuid::new_v4().to_string();
|
||||
let error_display = format!(
|
||||
"Failed tool call: `{}`\n\n{}",
|
||||
current_tool_name, error_text
|
||||
);
|
||||
yield Ok(build_add_agent_output_message(
|
||||
&task_id,
|
||||
&error_msg_id,
|
||||
&error_display,
|
||||
));
|
||||
if let Some(ref logger) = diagnostic_logger {
|
||||
logger.log_stream_event(&format!(
|
||||
"UnknownToolCall: name={}, id={} — synthesized error result",
|
||||
@@ -496,7 +552,7 @@ pub fn bedrock_stream_to_response_events(
|
||||
cache_read_input_tokens,
|
||||
cache_write_input_tokens,
|
||||
&model_id,
|
||||
is_summarization,
|
||||
false,
|
||||
);
|
||||
yield Ok(finished_event);
|
||||
};
|
||||
@@ -537,12 +593,10 @@ fn build_user_query_message(task_id: &str, query_text: &str) -> ResponseEvent {
|
||||
timestamp: None,
|
||||
server_message_data: String::new(),
|
||||
citations: vec![],
|
||||
message: Some(api::message::Message::UserQuery(
|
||||
api::message::UserQuery {
|
||||
query: query_text.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
|
||||
query: query_text.to_string(),
|
||||
..Default::default()
|
||||
})),
|
||||
};
|
||||
|
||||
let action = ClientAction {
|
||||
@@ -656,7 +710,7 @@ pub(super) fn build_stream_finished(
|
||||
/// Nova Lite: input $0.06, output $0.24
|
||||
/// Nova Micro: input $0.035, output $0.14
|
||||
/// DeepSeek R1: input $1.35, output $5.40
|
||||
fn estimate_cost_cents(
|
||||
pub fn estimate_cost_cents(
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
cache_read_tokens: u32,
|
||||
@@ -666,23 +720,22 @@ fn estimate_cost_cents(
|
||||
let lower = model_id.to_lowercase();
|
||||
|
||||
// (input_per_1m, output_per_1m, cache_read_per_1m, cache_write_per_1m) in dollars
|
||||
let (input_rate, output_rate, cache_read_rate, cache_write_rate) =
|
||||
if lower.contains("opus") {
|
||||
(15.0, 75.0, 1.50, 18.75)
|
||||
} else if lower.contains("haiku") {
|
||||
(0.80, 4.0, 0.08, 1.0)
|
||||
} else if lower.contains("nova-pro") {
|
||||
(0.80, 3.20, 0.0, 0.0)
|
||||
} else if lower.contains("nova-lite") {
|
||||
(0.06, 0.24, 0.0, 0.0)
|
||||
} else if lower.contains("nova-micro") {
|
||||
(0.035, 0.14, 0.0, 0.0)
|
||||
} else if lower.contains("deepseek") {
|
||||
(1.35, 5.40, 0.0, 0.0)
|
||||
} else {
|
||||
// Default to Sonnet pricing
|
||||
(3.0, 15.0, 0.30, 3.75)
|
||||
};
|
||||
let (input_rate, output_rate, cache_read_rate, cache_write_rate) = if lower.contains("opus") {
|
||||
(15.0, 75.0, 1.50, 18.75)
|
||||
} else if lower.contains("haiku") {
|
||||
(0.80, 4.0, 0.08, 1.0)
|
||||
} else if lower.contains("nova-pro") {
|
||||
(0.80, 3.20, 0.0, 0.0)
|
||||
} else if lower.contains("nova-lite") {
|
||||
(0.06, 0.24, 0.0, 0.0)
|
||||
} else if lower.contains("nova-micro") {
|
||||
(0.035, 0.14, 0.0, 0.0)
|
||||
} else if lower.contains("deepseek") {
|
||||
(1.35, 5.40, 0.0, 0.0)
|
||||
} else {
|
||||
// Default to Sonnet pricing
|
||||
(3.0, 15.0, 0.30, 3.75)
|
||||
};
|
||||
|
||||
// Convert from dollars per 1M tokens to cents per token
|
||||
let input_cost = input_tokens as f64 * input_rate * 100.0 / 1_000_000.0;
|
||||
@@ -914,22 +967,22 @@ fn build_tool_call_message(
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(api::message::tool_call::Tool::WriteToLongRunningShellCommand(
|
||||
api::message::tool_call::WriteToLongRunningShellCommand {
|
||||
input: text_input.into_bytes(),
|
||||
mode: None,
|
||||
command_id: String::new(),
|
||||
},
|
||||
))
|
||||
}
|
||||
"read_shell_command_output" => {
|
||||
Some(api::message::tool_call::Tool::ReadShellCommandOutput(
|
||||
api::message::tool_call::ReadShellCommandOutput {
|
||||
command_id: String::new(),
|
||||
delay: None,
|
||||
},
|
||||
))
|
||||
Some(
|
||||
api::message::tool_call::Tool::WriteToLongRunningShellCommand(
|
||||
api::message::tool_call::WriteToLongRunningShellCommand {
|
||||
input: text_input.into_bytes(),
|
||||
mode: None,
|
||||
command_id: String::new(),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
"read_shell_command_output" => Some(api::message::tool_call::Tool::ReadShellCommandOutput(
|
||||
api::message::tool_call::ReadShellCommandOutput {
|
||||
command_id: String::new(),
|
||||
delay: None,
|
||||
},
|
||||
)),
|
||||
"read_mcp_resource" => {
|
||||
let server_id = input
|
||||
.get("server_id")
|
||||
@@ -942,10 +995,7 @@ fn build_tool_call_message(
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(api::message::tool_call::Tool::ReadMcpResource(
|
||||
api::message::tool_call::ReadMcpResource {
|
||||
uri,
|
||||
server_id,
|
||||
},
|
||||
api::message::tool_call::ReadMcpResource { uri, server_id },
|
||||
))
|
||||
}
|
||||
"read_documents" => {
|
||||
@@ -994,8 +1044,16 @@ fn build_tool_call_message(
|
||||
.filter_map(|d| {
|
||||
Some(api::message::tool_call::edit_documents::DocumentDiff {
|
||||
document_id: d.get("document_id")?.as_str()?.to_string(),
|
||||
search: d.get("search").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
replace: d.get("replace").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
search: d
|
||||
.get("search")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
replace: d
|
||||
.get("replace")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -1006,20 +1064,34 @@ fn build_tool_call_message(
|
||||
))
|
||||
}
|
||||
"start_agent" => {
|
||||
let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let prompt = input.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
Some(api::message::tool_call::Tool::StartAgent(
|
||||
api::StartAgent {
|
||||
name,
|
||||
prompt,
|
||||
execution_mode: None,
|
||||
lifecycle_subscription: None,
|
||||
},
|
||||
))
|
||||
let name = input
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let prompt = input
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(api::message::tool_call::Tool::StartAgent(api::StartAgent {
|
||||
name,
|
||||
prompt,
|
||||
execution_mode: None,
|
||||
lifecycle_subscription: None,
|
||||
}))
|
||||
}
|
||||
"send_message_to_agent" => {
|
||||
let agent_id = input.get("agent_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let message = input.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let agent_id = input
|
||||
.get("agent_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let message = input
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(api::message::tool_call::Tool::SendMessageToAgent(
|
||||
api::SendMessageToAgent {
|
||||
addresses: vec![agent_id],
|
||||
@@ -1029,28 +1101,36 @@ fn build_tool_call_message(
|
||||
))
|
||||
}
|
||||
"ask_user_question" => {
|
||||
let question_text = input.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let question_text = input
|
||||
.get("question")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let options: Vec<api::ask_user_question::Option> = input
|
||||
.get("options")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|o| o.as_str())
|
||||
.map(|label| api::ask_user_question::Option { label: label.to_string() })
|
||||
.map(|label| api::ask_user_question::Option {
|
||||
label: label.to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let question = api::ask_user_question::Question {
|
||||
question_id: Uuid::new_v4().to_string(),
|
||||
question: question_text,
|
||||
question_type: Some(api::ask_user_question::question::QuestionType::MultipleChoice(
|
||||
api::ask_user_question::MultipleChoice {
|
||||
options,
|
||||
is_multiselect: false,
|
||||
supports_other: true,
|
||||
recommended_option_index: 0,
|
||||
},
|
||||
)),
|
||||
question_type: Some(
|
||||
api::ask_user_question::question::QuestionType::MultipleChoice(
|
||||
api::ask_user_question::MultipleChoice {
|
||||
options,
|
||||
is_multiselect: false,
|
||||
supports_other: true,
|
||||
recommended_option_index: 0,
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
Some(api::message::tool_call::Tool::AskUserQuestion(
|
||||
api::AskUserQuestion {
|
||||
@@ -1059,7 +1139,11 @@ fn build_tool_call_message(
|
||||
))
|
||||
}
|
||||
"read_skill" => {
|
||||
let skill = input.get("skill").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let skill = input
|
||||
.get("skill")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(api::message::tool_call::Tool::ReadSkill(
|
||||
api::message::tool_call::ReadSkill {
|
||||
name: skill.clone(),
|
||||
@@ -1070,7 +1154,11 @@ fn build_tool_call_message(
|
||||
))
|
||||
}
|
||||
"fetch_conversation" => {
|
||||
let conversation_id = input.get("conversation_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let conversation_id = input
|
||||
.get("conversation_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
Some(api::message::tool_call::Tool::FetchConversation(
|
||||
api::message::tool_call::FetchConversation { conversation_id },
|
||||
))
|
||||
@@ -1103,7 +1191,10 @@ fn build_tool_call_message(
|
||||
let (server_name, mcp_tool_name) = if parts.len() == 3 {
|
||||
(parts[1].to_string(), parts[2].to_string())
|
||||
} else {
|
||||
(String::new(), name.strip_prefix("mcp__").unwrap_or(name).to_string())
|
||||
(
|
||||
String::new(),
|
||||
name.strip_prefix("mcp__").unwrap_or(name).to_string(),
|
||||
)
|
||||
};
|
||||
let args = json_to_prost_struct(&input);
|
||||
Some(api::message::tool_call::Tool::CallMcpTool(
|
||||
@@ -1193,8 +1284,143 @@ const KNOWN_TOOLS: &[&str] = &[
|
||||
"suggest_next_prompt",
|
||||
"read_skill",
|
||||
"fetch_conversation",
|
||||
"recall_tool_history",
|
||||
];
|
||||
|
||||
fn is_known_tool(name: &str) -> bool {
|
||||
KNOWN_TOOLS.contains(&name) || name.starts_with("mcp__")
|
||||
}
|
||||
|
||||
/// Searches conversation message history for tool call results matching the given criteria.
|
||||
fn recall_from_history(
|
||||
messages: &[ConversationMessage],
|
||||
archive: &[ConversationMessage],
|
||||
search_query: &str,
|
||||
tool_name_filter: &str,
|
||||
tool_use_id: &str,
|
||||
offset_from_end: usize,
|
||||
) -> String {
|
||||
use super::convert::{ContentPart, MessageContent};
|
||||
|
||||
struct ToolEntry {
|
||||
tool_use_id: String,
|
||||
name: String,
|
||||
input: String,
|
||||
result: String,
|
||||
}
|
||||
|
||||
let mut tool_entries: Vec<ToolEntry> = Vec::new();
|
||||
|
||||
let mut pending_tool_uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
|
||||
|
||||
for msg in messages.iter().chain(archive.iter()) {
|
||||
match &msg.content {
|
||||
MessageContent::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
} => {
|
||||
pending_tool_uses.push((tool_use_id.clone(), name.clone(), input.to_string()));
|
||||
}
|
||||
MessageContent::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
if let Some(pos) = pending_tool_uses
|
||||
.iter()
|
||||
.position(|(id, _, _)| id == tool_use_id)
|
||||
{
|
||||
let (tuid, name, input) = pending_tool_uses.remove(pos);
|
||||
tool_entries.push(ToolEntry {
|
||||
tool_use_id: tuid,
|
||||
name,
|
||||
input,
|
||||
result: content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
MessageContent::MultiPart(parts) => {
|
||||
for part in parts {
|
||||
match part {
|
||||
ContentPart::ToolUse {
|
||||
tool_use_id,
|
||||
name,
|
||||
input,
|
||||
} => {
|
||||
pending_tool_uses.push((
|
||||
tool_use_id.clone(),
|
||||
name.clone(),
|
||||
input.to_string(),
|
||||
));
|
||||
}
|
||||
ContentPart::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
if let Some(pos) = pending_tool_uses
|
||||
.iter()
|
||||
.position(|(id, _, _)| id == tool_use_id)
|
||||
{
|
||||
let (tuid, name, input) = pending_tool_uses.remove(pos);
|
||||
tool_entries.push(ToolEntry {
|
||||
tool_use_id: tuid,
|
||||
name,
|
||||
input,
|
||||
result: content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let filtered: Vec<&ToolEntry> = tool_entries
|
||||
.iter()
|
||||
.filter(|entry| {
|
||||
if !tool_use_id.is_empty() && entry.tool_use_id != tool_use_id {
|
||||
return false;
|
||||
}
|
||||
if !tool_name_filter.is_empty() && entry.name != tool_name_filter {
|
||||
return false;
|
||||
}
|
||||
if !search_query.is_empty() {
|
||||
let haystack = format!("{} {} {}", entry.name, entry.input, entry.result);
|
||||
let query_lower = search_query.to_lowercase();
|
||||
if !haystack.to_lowercase().contains(&query_lower) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
return "No matching tool calls found in conversation history.".to_string();
|
||||
}
|
||||
|
||||
// Get the entry at offset_from_end (0 = most recent)
|
||||
let idx = if offset_from_end >= filtered.len() {
|
||||
0
|
||||
} else {
|
||||
filtered.len() - 1 - offset_from_end
|
||||
};
|
||||
|
||||
let entry = &filtered[idx];
|
||||
let result_display = if entry.result.len() > 50000 {
|
||||
let trunc = entry.result.chars().take(50000).collect::<String>();
|
||||
format!("{trunc}... [truncated, {} total chars]", entry.result.len())
|
||||
} else {
|
||||
entry.result.clone()
|
||||
};
|
||||
|
||||
format!(
|
||||
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{}",
|
||||
entry.name, entry.tool_use_id, entry.input, result_display
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ fn test_build_stream_init_has_valid_ids() {
|
||||
#[test]
|
||||
fn test_build_stream_finished_done_reason() {
|
||||
let reason = stream_finished::Reason::Done(stream_finished::Done {});
|
||||
let event = build_stream_finished(reason, 100, 50, 20, 10, "anthropic.claude-sonnet-4-6");
|
||||
let event = build_stream_finished(reason, 100, 50, 20, 10, "anthropic.claude-sonnet-4-6", false);
|
||||
|
||||
match event.r#type {
|
||||
Some(api::response_event::Type::Finished(finished)) => {
|
||||
@@ -53,7 +53,7 @@ fn test_build_stream_finished_done_reason() {
|
||||
#[test]
|
||||
fn test_build_stream_finished_max_token_limit() {
|
||||
let reason = stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {});
|
||||
let event = build_stream_finished(reason, 200, 100, 0, 0, "anthropic.claude-sonnet-4-6");
|
||||
let event = build_stream_finished(reason, 200, 100, 0, 0, "anthropic.claude-sonnet-4-6", false);
|
||||
|
||||
match event.r#type {
|
||||
Some(api::response_event::Type::Finished(finished)) => {
|
||||
@@ -69,7 +69,7 @@ fn test_build_stream_finished_max_token_limit() {
|
||||
#[test]
|
||||
fn test_build_stream_finished_other_reason() {
|
||||
let reason = stream_finished::Reason::Other(stream_finished::Other {});
|
||||
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6");
|
||||
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6", false);
|
||||
|
||||
match event.r#type {
|
||||
Some(api::response_event::Type::Finished(finished)) => {
|
||||
@@ -121,16 +121,34 @@ fn test_build_create_task_has_no_parent() {
|
||||
|
||||
#[test]
|
||||
fn test_context_window_for_model_1m_marker() {
|
||||
assert_eq!(context_window_for_model("anthropic.claude-opus-4-6[1m]"), 1_000_000);
|
||||
assert_eq!(context_window_for_model("us.anthropic.claude-opus-4-6[1M]"), 1_000_000);
|
||||
assert_eq!(context_window_for_model("anthropic.claude-sonnet-4-6[1m]"), 1_000_000);
|
||||
assert_eq!(
|
||||
context_window_for_model("anthropic.claude-opus-4-6[1m]"),
|
||||
1_000_000
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("us.anthropic.claude-opus-4-6[1M]"),
|
||||
1_000_000
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("anthropic.claude-sonnet-4-6[1m]"),
|
||||
1_000_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_window_for_model_standard_claude() {
|
||||
assert_eq!(context_window_for_model("anthropic.claude-opus-4-6"), 200_000);
|
||||
assert_eq!(context_window_for_model("us.anthropic.claude-sonnet-4-6"), 200_000);
|
||||
assert_eq!(context_window_for_model("anthropic.claude-haiku-4-5-20251001-v1:0"), 200_000);
|
||||
assert_eq!(
|
||||
context_window_for_model("anthropic.claude-opus-4-6"),
|
||||
200_000
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("us.anthropic.claude-sonnet-4-6"),
|
||||
200_000
|
||||
);
|
||||
assert_eq!(
|
||||
context_window_for_model("anthropic.claude-haiku-4-5-20251001-v1:0"),
|
||||
200_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -148,11 +166,35 @@ fn test_context_window_for_model_deepseek() {
|
||||
#[test]
|
||||
fn test_cost_varies_by_model() {
|
||||
let reason = stream_finished::Reason::Done(stream_finished::Done {});
|
||||
let opus_event = build_stream_finished(reason.clone(), 1000, 1000, 0, 0, "anthropic.claude-opus-4-6");
|
||||
let opus_event = build_stream_finished(
|
||||
reason.clone(),
|
||||
1000,
|
||||
1000,
|
||||
0,
|
||||
0,
|
||||
"anthropic.claude-opus-4-6",
|
||||
false,
|
||||
);
|
||||
let reason = stream_finished::Reason::Done(stream_finished::Done {});
|
||||
let sonnet_event = build_stream_finished(reason.clone(), 1000, 1000, 0, 0, "anthropic.claude-sonnet-4-6");
|
||||
let sonnet_event = build_stream_finished(
|
||||
reason.clone(),
|
||||
1000,
|
||||
1000,
|
||||
0,
|
||||
0,
|
||||
"anthropic.claude-sonnet-4-6",
|
||||
false,
|
||||
);
|
||||
let reason = stream_finished::Reason::Done(stream_finished::Done {});
|
||||
let haiku_event = build_stream_finished(reason, 1000, 1000, 0, 0, "anthropic.claude-haiku-4-5-20251001-v1:0");
|
||||
let haiku_event = build_stream_finished(
|
||||
reason,
|
||||
1000,
|
||||
1000,
|
||||
0,
|
||||
0,
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
false,
|
||||
);
|
||||
|
||||
let opus_cost = match opus_event.r#type {
|
||||
Some(api::response_event::Type::Finished(f)) => f.token_usage[0].cost_in_cents,
|
||||
@@ -168,14 +210,20 @@ fn test_cost_varies_by_model() {
|
||||
};
|
||||
|
||||
assert!(opus_cost > sonnet_cost, "Opus should cost more than Sonnet");
|
||||
assert!(sonnet_cost > haiku_cost, "Sonnet should cost more than Haiku");
|
||||
assert!(haiku_cost > 0.0, "All costs should be positive for non-zero tokens");
|
||||
assert!(
|
||||
sonnet_cost > haiku_cost,
|
||||
"Sonnet should cost more than Haiku"
|
||||
);
|
||||
assert!(
|
||||
haiku_cost > 0.0,
|
||||
"All costs should be positive for non-zero tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cost_zero_for_zero_tokens() {
|
||||
let reason = stream_finished::Reason::Done(stream_finished::Done {});
|
||||
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6");
|
||||
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6", false);
|
||||
|
||||
let cost = match event.r#type {
|
||||
Some(api::response_event::Type::Finished(f)) => f.token_usage[0].cost_in_cents,
|
||||
|
||||
@@ -3,9 +3,7 @@ use crate::ai::bedrock::external_config::ExternalBedrockConfig;
|
||||
use crate::appearance::Appearance;
|
||||
use crate::ui_components::blended_colors;
|
||||
use galaxyui::{
|
||||
elements::{
|
||||
Container, CornerRadius, CrossAxisAlignment, Flex, ParentElement, Radius, Text,
|
||||
},
|
||||
elements::{Container, CornerRadius, CrossAxisAlignment, Flex, ParentElement, Radius, Text},
|
||||
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
|
||||
};
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ pub struct TranslatorRequest {
|
||||
pub model_id: String,
|
||||
pub root_task_id: Option<String>,
|
||||
pub bedrock_message_history: Vec<ConversationMessage>,
|
||||
pub bedrock_compact_summary: Option<String>,
|
||||
pub bedrock_tool_result_archive: Vec<ConversationMessage>,
|
||||
pub bedrock_progressive_summary: Option<String>,
|
||||
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
|
||||
pub is_summarization: bool,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
@@ -45,7 +45,7 @@ pub async fn execute(
|
||||
let mut model_id = params.model_id;
|
||||
if model_id.is_empty() || model_id == "auto" {
|
||||
// Fall back to default if nothing is set
|
||||
model_id = "us.anthropic.claude-opus-4-6".to_string();
|
||||
model_id = "us.anthropic.claude-opus-4-6[1m]".to_string();
|
||||
}
|
||||
|
||||
log::info!("[bedrock] Translator: model={model_id}, task_id={task_id}, needs_create_task={needs_create_task}");
|
||||
@@ -60,13 +60,38 @@ pub async fn execute(
|
||||
request_translator::inject_input_messages_into_task(request);
|
||||
|
||||
let new_input_messages = request_translator::extract_new_input_messages(request);
|
||||
let new_input_count = new_input_messages.len();
|
||||
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// Prepend progressive summary as the first message pair if present
|
||||
if let Some(ref summary) = params.bedrock_progressive_summary {
|
||||
use crate::ai::bedrock::convert::{MessageContent, MessageRole};
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(format!(
|
||||
"<conversation-history-summary>\n{}\n</conversation-history-summary>\n\n\
|
||||
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges.",
|
||||
summary
|
||||
)),
|
||||
});
|
||||
messages.push(ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::Text(
|
||||
"Understood, I have the prior context. Continuing with the recent conversation."
|
||||
.to_string(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let history_len = params.bedrock_message_history.len();
|
||||
messages.extend(params.bedrock_message_history);
|
||||
|
||||
let mut messages = params.bedrock_message_history;
|
||||
if !new_input_messages.is_empty() {
|
||||
log::info!(
|
||||
"[bedrock] Appending {} new input messages to history of {}",
|
||||
new_input_messages.len(),
|
||||
messages.len()
|
||||
history_len
|
||||
);
|
||||
messages.extend(new_input_messages);
|
||||
}
|
||||
@@ -74,20 +99,24 @@ pub async fn execute(
|
||||
request_translator::sanitize_messages_for_bedrock(&mut messages);
|
||||
|
||||
let system_prompt = request_translator::extract_system_prompt(request);
|
||||
let compact_summary = params.bedrock_compact_summary;
|
||||
let tools = request_translator::extract_tools(request);
|
||||
|
||||
log::info!(
|
||||
"[bedrock] Sending {} messages, system_prompt={}, compact_summary={}, tools={}",
|
||||
"[bedrock] Sending {} messages, system_prompt={}, progressive_summary={}, tools={}",
|
||||
messages.len(),
|
||||
system_prompt.is_some(),
|
||||
compact_summary.is_some(),
|
||||
params.bedrock_progressive_summary.is_some(),
|
||||
tools.len()
|
||||
);
|
||||
|
||||
for (i, msg) in messages.iter().enumerate() {
|
||||
let content_desc = describe_message_content(&msg.content);
|
||||
log::info!("[bedrock] msg[{}]: role={:?}, content={}", i, msg.role, content_desc);
|
||||
log::info!(
|
||||
"[bedrock] msg[{}]: role={:?}, content={}",
|
||||
i,
|
||||
msg.role,
|
||||
content_desc
|
||||
);
|
||||
}
|
||||
|
||||
let user_query_text = request_translator::extract_user_query_text(request);
|
||||
@@ -99,7 +128,7 @@ pub async fn execute(
|
||||
needs_create_task,
|
||||
messages.clone(),
|
||||
system_prompt,
|
||||
compact_summary,
|
||||
None, // progressive summary is in messages array, not system prompt
|
||||
tools,
|
||||
64000,
|
||||
None,
|
||||
@@ -107,12 +136,20 @@ pub async fn execute(
|
||||
user_query_text,
|
||||
diagnostic_logger,
|
||||
params.bedrock_messages_sent.clone(),
|
||||
params.is_summarization,
|
||||
params.bedrock_tool_result_archive,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
|
||||
*sent = messages;
|
||||
// Only persist the actual conversation history (history + new inputs), not the
|
||||
// ephemeral prepended summary pair, so we don't duplicate the summary on every
|
||||
// subsequent write-back. The summary is prepended at request time each turn.
|
||||
let persistent_count = history_len + new_input_count;
|
||||
if persistent_count > 0 && messages.len() >= persistent_count {
|
||||
*sent = messages.split_off(messages.len() - persistent_count);
|
||||
} else {
|
||||
*sent = messages;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stream)
|
||||
|
||||
Reference in New Issue
Block a user