forked from boos/BoosAPI
feat(desktop): embed BoosAPI Desktop client as a subproject
Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as a monorepo subdirectory under desktop/. Source only — node_modules, dist, Cargo target and the upstream .git history are excluded. desktop/ keeps its own independent Cargo workspace (desktop/src-tauri with crates/*); it is not part of the backend boosapi crate, so a `cargo check` at the metapi root still compiles only the backend. Build the desktop app from desktop/src-tauri per desktop/README.md. Note: app icons are still placeholder assets pending the final BoosAPI logo. The updater signing keys live outside the repo and are not included.
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
//! BoosApiProviderBridge: adapts BoosApi's ProviderAdapter to the SDK's LLMProvider trait.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use open_agent_sdk::api::provider::ProviderRequest;
|
||||
use open_agent_sdk::api::ApiError;
|
||||
use open_agent_sdk::types::{ImageContentSource, ToolResultContentBlock};
|
||||
use open_agent_sdk::{
|
||||
ApiType, ContentBlock, LLMProvider, Message, MessageRole, ProviderResponse, SDKMessage, Usage,
|
||||
};
|
||||
|
||||
use boosapi_core::types::{
|
||||
ChatContent, ChatMessage, ChatRequest, ChatTool, ChatToolFunction, ContentPart, ImageUrl,
|
||||
ModelParamOverrides, TokenUsage, ToolCall, ToolCallFunction,
|
||||
};
|
||||
use boosapi_providers::{ProviderAdapter, ProviderRequestContext};
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Bridge between BoosApi providers and the open-agent-sdk LLMProvider interface.
|
||||
pub struct BoosApiProviderBridge {
|
||||
adapter: Arc<dyn ProviderAdapter>,
|
||||
ctx: ProviderRequestContext,
|
||||
api_type: ApiType,
|
||||
model_param_overrides: Option<ModelParamOverrides>,
|
||||
app: Option<tauri::AppHandle>,
|
||||
conversation_id: Option<String>,
|
||||
}
|
||||
|
||||
impl BoosApiProviderBridge {
|
||||
pub fn new(
|
||||
adapter: Arc<dyn ProviderAdapter>,
|
||||
ctx: ProviderRequestContext,
|
||||
provider_type: &str,
|
||||
) -> Result<Self, String> {
|
||||
let api_type = match provider_type {
|
||||
"anthropic" => ApiType::AnthropicMessages,
|
||||
"openai" => ApiType::OpenAICompletions,
|
||||
"gemini" => ApiType::OpenAICompletions,
|
||||
"custom" => ApiType::OpenAICompletions,
|
||||
"openai_responses" => ApiType::OpenAICompletions,
|
||||
"deepseek" => ApiType::OpenAICompletions,
|
||||
"xai" => ApiType::OpenAICompletions,
|
||||
"glm" => ApiType::OpenAICompletions,
|
||||
"siliconflow" => ApiType::OpenAICompletions,
|
||||
other => {
|
||||
tracing::warn!(
|
||||
"Unknown provider type '{}', defaulting to OpenAI compat",
|
||||
other
|
||||
);
|
||||
ApiType::OpenAICompletions
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
adapter,
|
||||
ctx,
|
||||
api_type,
|
||||
model_param_overrides: None,
|
||||
app: None,
|
||||
conversation_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_model_param_overrides(mut self, overrides: Option<ModelParamOverrides>) -> Self {
|
||||
self.model_param_overrides = overrides;
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach a Tauri AppHandle for streaming text chunks to the frontend.
|
||||
pub fn with_app(mut self, app: tauri::AppHandle, conversation_id: String) -> Self {
|
||||
self.app = Some(app);
|
||||
self.conversation_id = Some(conversation_id);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LLMProvider for BoosApiProviderBridge {
|
||||
fn api_type(&self) -> ApiType {
|
||||
self.api_type.clone()
|
||||
}
|
||||
|
||||
async fn create_message(
|
||||
&self,
|
||||
request: ProviderRequest<'_>,
|
||||
stream_tx: Option<tokio::sync::mpsc::Sender<SDKMessage>>,
|
||||
) -> Result<ProviderResponse, ApiError> {
|
||||
let chat_request = convert_request(request, self.model_param_overrides.as_ref());
|
||||
|
||||
let mut stream = self.adapter.chat_stream(&self.ctx, chat_request);
|
||||
let mut accumulated_text = String::new();
|
||||
let mut accumulated_thinking = String::new();
|
||||
let mut final_tool_calls: Option<Vec<ToolCall>> = None;
|
||||
let mut final_usage: Option<TokenUsage> = None;
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
match result {
|
||||
Ok(chunk) => {
|
||||
if let Some(ref text) = chunk.content {
|
||||
if !text.is_empty() {
|
||||
accumulated_text.push_str(text);
|
||||
|
||||
// Emit streaming text delta via SDK channel
|
||||
// (agent.rs will forward to frontend as agent-stream-text)
|
||||
if let Some(ref tx) = stream_tx {
|
||||
let _ = tx.try_send(SDKMessage::TextDelta { text: text.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref thinking) = chunk.thinking {
|
||||
if !thinking.is_empty() {
|
||||
accumulated_thinking.push_str(thinking);
|
||||
|
||||
// Emit streaming thinking delta via SDK channel
|
||||
// (agent.rs will forward to frontend as agent-stream-thinking)
|
||||
if let Some(ref tx) = stream_tx {
|
||||
let _ = tx.try_send(SDKMessage::ThinkingDelta {
|
||||
thinking: thinking.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chunk.tool_calls.is_some() {
|
||||
final_tool_calls.clone_from(&chunk.tool_calls);
|
||||
}
|
||||
|
||||
if chunk.usage.is_some() {
|
||||
final_usage.clone_from(&chunk.usage);
|
||||
}
|
||||
|
||||
if chunk.done {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(classify_provider_error(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let usage = final_usage.unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
});
|
||||
|
||||
let response = boosapi_core::types::ChatResponse {
|
||||
id: String::new(),
|
||||
model: String::new(),
|
||||
content: accumulated_text,
|
||||
thinking: if accumulated_thinking.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(accumulated_thinking)
|
||||
},
|
||||
usage,
|
||||
tool_calls: final_tool_calls,
|
||||
};
|
||||
|
||||
Ok(convert_response(response))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK ProviderRequest → BoosApi ChatRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn convert_request(
|
||||
request: ProviderRequest<'_>,
|
||||
model_param_overrides: Option<&ModelParamOverrides>,
|
||||
) -> ChatRequest {
|
||||
let messages: Vec<ChatMessage> = request
|
||||
.messages
|
||||
.iter()
|
||||
.flat_map(convert_sdk_message_to_chat_messages)
|
||||
.collect();
|
||||
|
||||
let tools: Option<Vec<ChatTool>> = request.tools.as_ref().map(|tools| {
|
||||
tools
|
||||
.iter()
|
||||
.map(|t| ChatTool {
|
||||
r#type: "function".to_string(),
|
||||
function: ChatToolFunction {
|
||||
name: t.name.clone(),
|
||||
description: Some(t.description.clone()),
|
||||
parameters: Some(t.input_schema.clone()),
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let system_text: Option<String> = request.system.as_ref().map(|blocks| {
|
||||
blocks
|
||||
.iter()
|
||||
.map(|b| b.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n")
|
||||
});
|
||||
|
||||
let mut final_messages = Vec::new();
|
||||
if let Some(sys) = system_text {
|
||||
final_messages.push(ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: ChatContent::Text(sys),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
final_messages.extend(messages);
|
||||
|
||||
let force_model_max_tokens = model_param_overrides
|
||||
.and_then(|overrides| overrides.force_max_tokens)
|
||||
== Some(true);
|
||||
let max_tokens = if request.max_tokens > 0 {
|
||||
Some(request.max_tokens as u32)
|
||||
} else if force_model_max_tokens {
|
||||
model_param_overrides
|
||||
.and_then(|overrides| overrides.max_tokens)
|
||||
.or(Some(4096))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
ChatRequest {
|
||||
model: request.model.to_string(),
|
||||
messages: final_messages,
|
||||
stream: true,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens,
|
||||
tools,
|
||||
thinking_budget: request
|
||||
.thinking
|
||||
.as_ref()
|
||||
.and_then(|t| t.budget_tokens.map(|b| b as u32)),
|
||||
thinking_level: None,
|
||||
reasoning_profile: model_param_overrides
|
||||
.and_then(|overrides| overrides.reasoning_profile.clone()),
|
||||
use_max_completion_tokens: model_param_overrides
|
||||
.and_then(|overrides| overrides.use_max_completion_tokens),
|
||||
thinking_param_style: model_param_overrides
|
||||
.and_then(|overrides| overrides.thinking_param_style.clone()),
|
||||
extra_body: model_param_overrides.and_then(|overrides| overrides.extra_body.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a single SDK Message into one or more BoosApi ChatMessages.
|
||||
/// ToolResult content blocks become separate tool-role messages.
|
||||
fn convert_sdk_message_to_chat_messages(msg: &Message) -> Vec<ChatMessage> {
|
||||
let role = match msg.role {
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
};
|
||||
|
||||
let mut text_parts: Vec<String> = Vec::new();
|
||||
let mut thinking_parts: Vec<String> = Vec::new();
|
||||
let mut tool_calls: Vec<ToolCall> = Vec::new();
|
||||
let mut tool_results: Vec<(String, String)> = Vec::new();
|
||||
let mut image_parts: Vec<ImageContentSource> = Vec::new();
|
||||
|
||||
for block in &msg.content {
|
||||
match block {
|
||||
ContentBlock::Text { text } => {
|
||||
text_parts.push(text.clone());
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
tool_calls.push(ToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: ToolCallFunction {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
is_error: _,
|
||||
} => {
|
||||
let text = content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
ToolResultContentBlock::Text { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
tool_results.push((tool_use_id.clone(), text));
|
||||
}
|
||||
ContentBlock::Thinking { thinking, .. } => {
|
||||
if msg.role == MessageRole::Assistant && !thinking.is_empty() {
|
||||
thinking_parts.push(thinking.clone());
|
||||
}
|
||||
}
|
||||
ContentBlock::Image { source } => {
|
||||
image_parts.push(source.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
if role == "assistant" {
|
||||
let content = if text_parts.is_empty() {
|
||||
ChatContent::Text(String::new())
|
||||
} else {
|
||||
ChatContent::Text(text_parts.join(""))
|
||||
};
|
||||
|
||||
result.push(ChatMessage {
|
||||
role: "assistant".to_string(),
|
||||
content,
|
||||
reasoning_content: if thinking_parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(thinking_parts.join(""))
|
||||
},
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
tool_call_id: None,
|
||||
});
|
||||
} else if !tool_results.is_empty() {
|
||||
for (tool_use_id, text) in tool_results {
|
||||
result.push(ChatMessage {
|
||||
role: "tool".to_string(),
|
||||
content: ChatContent::Text(text),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let content = if !image_parts.is_empty() {
|
||||
let mut parts: Vec<ContentPart> = text_parts
|
||||
.iter()
|
||||
.map(|t| ContentPart {
|
||||
r#type: "text".to_string(),
|
||||
text: Some(t.clone()),
|
||||
image_url: None,
|
||||
})
|
||||
.collect();
|
||||
for img in &image_parts {
|
||||
parts.push(ContentPart {
|
||||
r#type: "image_url".to_string(),
|
||||
text: None,
|
||||
image_url: Some(ImageUrl {
|
||||
url: format!("data:{};base64,{}", img.media_type, img.data),
|
||||
}),
|
||||
});
|
||||
}
|
||||
ChatContent::Multipart(parts)
|
||||
} else {
|
||||
ChatContent::Text(text_parts.join(""))
|
||||
};
|
||||
|
||||
result.push(ChatMessage {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BoosApi ChatResponse → SDK ProviderResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn convert_response(response: boosapi_core::types::ChatResponse) -> ProviderResponse {
|
||||
let mut content_blocks: Vec<ContentBlock> = Vec::new();
|
||||
|
||||
if let Some(thinking) = &response.thinking {
|
||||
if !thinking.is_empty() {
|
||||
content_blocks.push(ContentBlock::Thinking {
|
||||
thinking: thinking.clone(),
|
||||
signature: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !response.content.is_empty() {
|
||||
content_blocks.push(ContentBlock::Text {
|
||||
text: response.content.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(tool_calls) = &response.tool_calls {
|
||||
for tc in tool_calls {
|
||||
let input: Value = serde_json::from_str(&tc.function.arguments).unwrap_or(Value::Null);
|
||||
content_blocks.push(ContentBlock::ToolUse {
|
||||
id: tc.id.clone(),
|
||||
name: tc.function.name.clone(),
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let stop_reason = if response
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map_or(false, |tc| !tc.is_empty())
|
||||
{
|
||||
Some("tool_use".to_string())
|
||||
} else {
|
||||
Some("end_turn".to_string())
|
||||
};
|
||||
|
||||
ProviderResponse {
|
||||
message: Message {
|
||||
role: MessageRole::Assistant,
|
||||
content: content_blocks,
|
||||
},
|
||||
usage: Usage {
|
||||
input_tokens: response.usage.prompt_tokens as u64,
|
||||
output_tokens: response.usage.completion_tokens as u64,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
stop_reason,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse an HTTP status code from provider error messages like "OpenAI API error 400 Bad Request: ..."
|
||||
fn parse_http_status(err: &str) -> Option<u16> {
|
||||
for pattern in &["API error ", "error "] {
|
||||
if let Some(pos) = err.find(pattern) {
|
||||
let after = &err[pos + pattern.len()..];
|
||||
if let Some(end) = after.find(|c: char| !c.is_ascii_digit()) {
|
||||
if end > 0 {
|
||||
if let Ok(status) = after[..end].parse::<u16>() {
|
||||
if (100..600).contains(&status) {
|
||||
return Some(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Classify a provider error into the appropriate SDK ApiError variant,
|
||||
/// so the retry logic only retries truly transient errors.
|
||||
fn classify_provider_error(e: boosapi_core::error::BoosApiError) -> ApiError {
|
||||
let err_str = e.to_string();
|
||||
if let Some(status) = parse_http_status(&err_str) {
|
||||
if status == 401 || status == 403 {
|
||||
ApiError::AuthError(err_str)
|
||||
} else if status == 429 {
|
||||
ApiError::RateLimitError
|
||||
} else if (400..500).contains(&status) {
|
||||
// Client errors (400, 404, 422, etc.) are NOT retryable
|
||||
ApiError::HttpError {
|
||||
status,
|
||||
message: err_str,
|
||||
}
|
||||
} else {
|
||||
// 5xx errors are retryable via NetworkError mapping
|
||||
ApiError::NetworkError(err_str)
|
||||
}
|
||||
} else {
|
||||
ApiError::NetworkError(err_str)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use open_agent_sdk::api::provider::ProviderRequest;
|
||||
|
||||
fn param_overrides() -> ModelParamOverrides {
|
||||
ModelParamOverrides {
|
||||
temperature: None,
|
||||
max_tokens: Some(2048),
|
||||
top_p: None,
|
||||
frequency_penalty: None,
|
||||
use_max_completion_tokens: Some(false),
|
||||
no_system_role: None,
|
||||
force_max_tokens: None,
|
||||
thinking_param_style: Some("enable_thinking".to_string()),
|
||||
reasoning_profile: Some("siliconflow_enable_thinking".to_string()),
|
||||
reasoning_options: None,
|
||||
reasoning_default: None,
|
||||
extra_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_request_applies_non_token_model_param_overrides() {
|
||||
let messages = vec![Message {
|
||||
role: MessageRole::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
}];
|
||||
let request = ProviderRequest {
|
||||
model: "deepseek-reasoner",
|
||||
max_tokens: 0,
|
||||
messages: &messages,
|
||||
system: None,
|
||||
tools: None,
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
let converted = convert_request(request, Some(¶m_overrides()));
|
||||
|
||||
assert_eq!(converted.max_tokens, None);
|
||||
assert_eq!(
|
||||
converted.reasoning_profile.as_deref(),
|
||||
Some("siliconflow_enable_thinking")
|
||||
);
|
||||
assert_eq!(converted.use_max_completion_tokens, Some(false));
|
||||
assert_eq!(
|
||||
converted.thinking_param_style.as_deref(),
|
||||
Some("enable_thinking")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_request_uses_model_max_tokens_only_when_forced() {
|
||||
let messages = vec![Message {
|
||||
role: MessageRole::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
}];
|
||||
let request = ProviderRequest {
|
||||
model: "deepseek-reasoner",
|
||||
max_tokens: 0,
|
||||
messages: &messages,
|
||||
system: None,
|
||||
tools: None,
|
||||
thinking: None,
|
||||
};
|
||||
let mut overrides = param_overrides();
|
||||
overrides.force_max_tokens = Some(true);
|
||||
|
||||
let converted = convert_request(request, Some(&overrides));
|
||||
|
||||
assert_eq!(converted.max_tokens, Some(2048));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_request_prefers_sdk_max_tokens_over_model_default() {
|
||||
let messages = vec![Message {
|
||||
role: MessageRole::User,
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
}];
|
||||
let request = ProviderRequest {
|
||||
model: "deepseek-reasoner",
|
||||
max_tokens: 8192,
|
||||
messages: &messages,
|
||||
system: None,
|
||||
tools: None,
|
||||
thinking: None,
|
||||
};
|
||||
|
||||
let converted = convert_request(request, Some(¶m_overrides()));
|
||||
|
||||
assert_eq!(converted.max_tokens, Some(8192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_thinking_block_becomes_reasoning_content() {
|
||||
let message = Message {
|
||||
role: MessageRole::Assistant,
|
||||
content: vec![
|
||||
ContentBlock::Thinking {
|
||||
thinking: "hidden reasoning".to_string(),
|
||||
signature: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "final answer".to_string(),
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
id: "call-1".to_string(),
|
||||
name: "read_file".to_string(),
|
||||
input: serde_json::json!({"path": "README.md"}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let converted = convert_sdk_message_to_chat_messages(&message);
|
||||
|
||||
assert_eq!(converted.len(), 1);
|
||||
assert_eq!(
|
||||
converted[0].reasoning_content.as_deref(),
|
||||
Some("hidden reasoning")
|
||||
);
|
||||
assert!(converted[0].tool_calls.is_some());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user