Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ tauri-plugin-log = "2"
tauri-plugin-autostart = "2"
tauri-plugin-notification = "2"
tauri-plugin-updater = "2"
tauri-plugin-global-shortcut = "2"
tauri-build = { version = "2", features = [] }

# Windows-specific dependencies
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ tauri-plugin-log = { workspace = true }
tauri-plugin-autostart = { workspace = true }
tauri-plugin-notification = { workspace = true }
tauri-plugin-updater = { workspace = true }
tauri-plugin-global-shortcut = { workspace = true }

# Inherited from workspace
tokio = { workspace = true }
Expand Down
9 changes: 7 additions & 2 deletions src/apps/desktop/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "BitFun default capabilities",
"windows": ["main", "agent-companion-pet"],
"windows": ["main", "agent-companion-pet", "spotlight"],
"permissions": [
"log:default",
"autostart:default",
Expand Down Expand Up @@ -105,6 +105,11 @@
"notification:allow-request-permission",
"notification:allow-check-permissions",
"notification:allow-permission-state",
"notification:allow-is-permission-granted"
"notification:allow-is-permission-granted",
"global-shortcut:default",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-unregister-all",
"global-shortcut:allow-is-registered"
]
}
76 changes: 76 additions & 0 deletions src/crates/ai-adapters/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,44 @@ mod tests {
assert_eq!(request_body["output_config"]["effort"], "high");
}

#[test]
fn build_anthropic_request_body_maps_enabled_to_adaptive_for_adaptive_models() {
let client = AIClient::new(AIConfig {
name: "anthropic".to_string(),
base_url: "https://api.anthropic.com".to_string(),
request_url: "https://api.anthropic.com/v1/messages".to_string(),
api_key: "test-key".to_string(),
model: "claude-sonnet-4-6".to_string(),
format: "anthropic".to_string(),
context_window: 200000,
max_tokens: Some(8192),
temperature: None,
top_p: None,
reasoning_mode: ReasoningMode::Enabled,
inline_think_in_text: false,
custom_headers: None,
custom_headers_mode: None,
skip_ssl_verify: false,
reasoning_effort: None,
thinking_budget_tokens: None,
custom_request_body: None,
custom_request_body_mode: None,
});

let request_body = anthropic::request::build_request_body(
&client,
&client.config.request_url,
None,
vec![json!({ "role": "user", "content": [{ "type": "text", "text": "hello" }] })],
None,
None,
);

assert_eq!(request_body["thinking"]["type"], "adaptive");
assert!(request_body["thinking"].get("budget_tokens").is_none());
assert_eq!(request_body["output_config"]["effort"], "medium");
}

#[test]
fn build_anthropic_request_body_adds_deepseek_reasoning_effort() {
let client = AIClient::new(AIConfig {
Expand Down Expand Up @@ -805,9 +843,47 @@ mod tests {
);

assert_eq!(request_body["thinking"]["type"], "enabled");
assert_eq!(request_body["thinking"]["budget_tokens"], 6144);
assert_eq!(request_body["output_config"]["effort"], "max");
}

#[test]
fn build_anthropic_request_body_enabled_reasoning_always_has_budget_tokens() {
let client = AIClient::new(AIConfig {
name: "anthropic-proxy".to_string(),
base_url: "https://proxy.example.com/anthropic".to_string(),
request_url: "https://proxy.example.com/anthropic/v1/messages".to_string(),
api_key: "test-key".to_string(),
model: "vendor-model-alias".to_string(),
format: "anthropic".to_string(),
context_window: 200000,
max_tokens: Some(4000),
temperature: None,
top_p: None,
reasoning_mode: ReasoningMode::Enabled,
inline_think_in_text: false,
custom_headers: None,
custom_headers_mode: None,
skip_ssl_verify: false,
reasoning_effort: None,
thinking_budget_tokens: None,
custom_request_body: None,
custom_request_body_mode: None,
});

let request_body = anthropic::request::build_request_body(
&client,
&client.config.request_url,
None,
vec![json!({ "role": "user", "content": [{ "type": "text", "text": "hello" }] })],
None,
None,
);

assert_eq!(request_body["thinking"]["type"], "enabled");
assert_eq!(request_body["thinking"]["budget_tokens"], 3000);
}

#[test]
fn build_openai_request_body_trim_mode_preserves_essential_fields() {
let mut client = make_trim_test_client("openai");
Expand Down
43 changes: 27 additions & 16 deletions src/crates/ai-adapters/src/providers/anthropic/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,26 @@ fn anthropic_supports_adaptive_reasoning(model_name: &str) -> bool {
)
}

fn anthropic_supports_thinking_budget(model_name: &str) -> bool {
model_name.starts_with("claude")
}

fn default_anthropic_budget_tokens(max_tokens: Option<u32>) -> Option<u32> {
max_tokens.map(|value| 10_000u32.min(value.saturating_mul(3) / 4))
}

fn anthropic_adaptive_effort(reasoning_effort: Option<&str>) -> &str {
reasoning_effort
.filter(|value| !value.trim().is_empty())
.unwrap_or("medium")
}

fn apply_anthropic_adaptive_reasoning(
request_body: &mut serde_json::Value,
reasoning_effort: Option<&str>,
) {
request_body["thinking"] = serde_json::json!({ "type": "adaptive" });
request_body["output_config"] = serde_json::json!({
"effort": anthropic_adaptive_effort(reasoning_effort)
});
}

fn apply_reasoning_fields(
request_body: &mut serde_json::Value,
mode: ReasoningMode,
Expand Down Expand Up @@ -81,13 +93,16 @@ fn apply_reasoning_fields(
request_body["thinking"] = serde_json::json!({ "type": "disabled" });
}
ReasoningMode::Enabled => {
if anthropic_supports_adaptive_reasoning(model_name) {
apply_anthropic_adaptive_reasoning(request_body, reasoning_effort);
return;
}

let mut thinking = serde_json::json!({ "type": "enabled" });
if anthropic_supports_thinking_budget(model_name) {
if let Some(budget_tokens) =
thinking_budget_tokens.or_else(|| default_anthropic_budget_tokens(max_tokens))
{
thinking["budget_tokens"] = serde_json::json!(budget_tokens);
}
if let Some(budget_tokens) =
thinking_budget_tokens.or_else(|| default_anthropic_budget_tokens(max_tokens))
{
thinking["budget_tokens"] = serde_json::json!(budget_tokens);
}
request_body["thinking"] = thinking;
if is_deepseek_reasoning_target {
Expand All @@ -101,12 +116,7 @@ fn apply_reasoning_fields(
}
ReasoningMode::Adaptive => {
if anthropic_supports_adaptive_reasoning(model_name) {
request_body["thinking"] = serde_json::json!({ "type": "adaptive" });
if let Some(effort) = reasoning_effort.filter(|value| !value.trim().is_empty()) {
request_body["output_config"] = serde_json::json!({
"effort": effort
});
}
apply_anthropic_adaptive_reasoning(request_body, reasoning_effort);
} else {
warn!(
target: "ai::anthropic_stream_request",
Expand All @@ -127,6 +137,7 @@ fn apply_reasoning_fields(
}

if mode != ReasoningMode::Adaptive
&& !anthropic_supports_adaptive_reasoning(model_name)
&& reasoning_effort.is_some_and(|value| !value.trim().is_empty())
{
warn!(
Expand Down
54 changes: 52 additions & 2 deletions src/crates/ai-adapters/src/providers/openai/message_converter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ impl OpenAIMessageConverter {
}
}
}
Self::trim_final_assistant_trailing_whitespace(&mut input);

let instructions = if instructions.is_empty() {
None
Expand All @@ -74,10 +75,39 @@ impl OpenAIMessageConverter {
}

pub fn convert_messages(messages: Vec<Message>) -> Vec<Value> {
messages
let mut messages = messages
.into_iter()
.map(Self::convert_single_message)
.collect()
.collect::<Vec<_>>();
Self::trim_final_assistant_trailing_whitespace(&mut messages);
messages
}

fn trim_final_assistant_trailing_whitespace(messages: &mut [Value]) {
let Some(last) = messages.last_mut() else {
return;
};
if last.get("role").and_then(Value::as_str) != Some("assistant") {
return;
}

match last.get_mut("content") {
Some(Value::String(text)) => {
let trimmed_len = text.trim_end().len();
text.truncate(trimmed_len);
}
Some(Value::Array(items)) => {
for item in items.iter_mut().rev() {
let Some(Value::String(last_text)) = item.get_mut("text") else {
continue;
};
let trimmed_len = last_text.trim_end().len();
last_text.truncate(trimmed_len);
break;
}
}
_ => {}
}
}

fn convert_tool_message_to_responses_item(msg: Message) -> Option<Value> {
Expand Down Expand Up @@ -546,4 +576,24 @@ mod tests {

assert_eq!(openai[0]["reasoning_content"], json!(""));
}

#[test]
fn trims_trailing_whitespace_from_final_assistant_prefill_for_chat_completions() {
let openai = OpenAIMessageConverter::convert_messages(vec![
Message::user("Generate the file content.".to_string()),
Message::assistant("<bitfun_contents>\n".to_string()),
]);

assert_eq!(openai[1]["content"], json!("<bitfun_contents>"));
}

#[test]
fn trims_trailing_whitespace_from_final_assistant_prefill_for_responses() {
let (_, input) = OpenAIMessageConverter::convert_messages_to_responses_input(vec![
Message::user("Generate the file content.".to_string()),
Message::assistant("<bitfun_contents>\n".to_string()),
]);

assert_eq!(input[1]["content"][0]["text"], json!("<bitfun_contents>"));
}
}
12 changes: 9 additions & 3 deletions src/crates/core/src/agentic/execution/round_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1004,7 +1004,7 @@ impl RoundExecutor {
function_call blocks, or agent framework syntax inside or outside the tags. \
You are not calling a tool here — you are outputting raw file content only.\n\
8. Do NOT repeat, summarize, or narrate prior tool calls (Read, Bash, Edit, etc.). Start writing the actual file body immediately.\n\
9. Do NOT output `[called tools:` markers, tool parameter JSON, or `<bitfun_contents>` / `</bitfun_contents>` tags — the opening tag is already provided via prefill.",
9. Do NOT output `[called tools:` markers, tool parameter JSON, or `<bitfun_contents>` / `</bitfun_contents>` tags — the opening tag is already provided via prefill. Begin with the first byte of the file content immediately after that opening tag.",
file_path = file_path
);

Expand Down Expand Up @@ -1097,7 +1097,7 @@ impl RoundExecutor {
) -> Vec<AIMessage> {
let mut content_messages = ai_messages.to_vec();
content_messages.push(AIMessage::user(content_prompt.to_string()));
content_messages.push(AIMessage::assistant("<bitfun_contents>\n".to_string()));
content_messages.push(AIMessage::assistant("<bitfun_contents>".to_string()));
content_messages
}

Expand Down Expand Up @@ -1941,7 +1941,13 @@ mod tests {
assert_eq!(messages[2].role, "tool");
assert_eq!(messages[3].role, "user");
assert_eq!(messages[4].role, "assistant");
assert_eq!(messages[4].content.as_deref(), Some("<bitfun_contents>\n"));
assert_eq!(messages[4].content.as_deref(), Some("<bitfun_contents>"));
assert!(
messages[4]
.content
.as_deref()
.is_some_and(|content| !content.ends_with(char::is_whitespace))
);
}

#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@
margin-bottom: 0;
animation: none;

.flowchat-flow-item--tool-transition,
.flowchat-flow-item--tool-active,
.flowchat-flow-item--tool-completed {
.flowchat-flow-item--tool-transition:not(.flowchat-flow-item--tool-completed),
.flowchat-flow-item--tool-active {
animation: none;
will-change: auto;
}
Expand Down
Loading