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,338 @@
|
||||
use async_trait::async_trait;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::types::{
|
||||
ApiToolParam, ContentBlock, ImageContentSource, Message, MessageRole,
|
||||
SDKMessage, SystemBlock, ThinkingConfig, Usage,
|
||||
};
|
||||
|
||||
use super::provider::{ApiType, LLMProvider, ProviderRequest, ProviderResponse};
|
||||
use super::ApiError;
|
||||
|
||||
const API_VERSION: &str = "2023-06-01";
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AnthropicRequest {
|
||||
model: String,
|
||||
max_tokens: u64,
|
||||
messages: Vec<AnthropicMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system: Option<Vec<SystemBlock>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<ApiToolParam>>,
|
||||
stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thinking: Option<ThinkingConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct AnthropicMessage {
|
||||
role: String,
|
||||
content: Value,
|
||||
}
|
||||
|
||||
/// Anthropic Messages API provider.
|
||||
pub struct AnthropicProvider {
|
||||
client: Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
custom_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl AnthropicProvider {
|
||||
pub fn new(
|
||||
client: Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
custom_headers: HashMap<String, String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
api_key,
|
||||
base_url,
|
||||
custom_headers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LLMProvider for AnthropicProvider {
|
||||
fn api_type(&self) -> ApiType {
|
||||
ApiType::AnthropicMessages
|
||||
}
|
||||
|
||||
async fn create_message(
|
||||
&self,
|
||||
request: ProviderRequest<'_>,
|
||||
stream_tx: Option<mpsc::Sender<SDKMessage>>,
|
||||
) -> Result<ProviderResponse, ApiError> {
|
||||
let api_messages: Vec<AnthropicMessage> = request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let role = match m.role {
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
};
|
||||
AnthropicMessage {
|
||||
role: role.to_string(),
|
||||
content: serde_json::to_value(&m.content).unwrap_or(Value::Array(vec![])),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = AnthropicRequest {
|
||||
model: request.model.to_string(),
|
||||
max_tokens: request.max_tokens,
|
||||
messages: api_messages,
|
||||
system: request.system,
|
||||
tools: if request.tools.as_ref().map_or(true, |t| t.is_empty()) {
|
||||
None
|
||||
} else {
|
||||
request.tools
|
||||
},
|
||||
stream: true,
|
||||
thinking: request.thinking,
|
||||
};
|
||||
|
||||
let mut req_builder = self
|
||||
.client
|
||||
.post(format!("{}/v1/messages", self.base_url))
|
||||
.header("x-api-key", &self.api_key)
|
||||
.header("anthropic-version", API_VERSION)
|
||||
.header("anthropic-beta", "prompt-caching-2024-07-31")
|
||||
.header("content-type", "application/json");
|
||||
|
||||
for (key, value) in &self.custom_headers {
|
||||
req_builder = req_builder.header(key, value);
|
||||
}
|
||||
|
||||
let response = req_builder.json(&body).send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ApiError::Timeout
|
||||
} else {
|
||||
ApiError::NetworkError(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
if status == 401 {
|
||||
return Err(ApiError::AuthError("Invalid API key".to_string()));
|
||||
}
|
||||
if status == 429 {
|
||||
return Err(ApiError::RateLimitError);
|
||||
}
|
||||
if status >= 400 {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
if text.contains("prompt is too long") {
|
||||
return Err(ApiError::PromptTooLong(text));
|
||||
}
|
||||
return Err(ApiError::HttpError {
|
||||
status,
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
|
||||
parse_anthropic_stream(response, stream_tx).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Anthropic SSE stream into a ProviderResponse, emitting real-time
|
||||
/// thinking/text deltas via the optional `stream_tx` channel.
|
||||
async fn parse_anthropic_stream(
|
||||
response: reqwest::Response,
|
||||
stream_tx: Option<mpsc::Sender<SDKMessage>>,
|
||||
) -> Result<ProviderResponse, ApiError> {
|
||||
let mut content_blocks: Vec<ContentBlock> = Vec::new();
|
||||
let mut usage = Usage::default();
|
||||
let mut stop_reason: Option<String> = None;
|
||||
let mut current_blocks: HashMap<usize, Value> = HashMap::new();
|
||||
|
||||
// Stream the response body line-by-line instead of buffering the entire body
|
||||
let mut byte_stream = response.bytes_stream();
|
||||
let mut line_buf = String::new();
|
||||
|
||||
while let Some(chunk_result) = byte_stream.next().await {
|
||||
let chunk = chunk_result.map_err(|e| ApiError::NetworkError(e.to_string()))?;
|
||||
line_buf.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
// Process all complete lines in the buffer
|
||||
while let Some(newline_pos) = line_buf.find('\n') {
|
||||
let raw_line = line_buf[..newline_pos].trim_end_matches('\r').to_string();
|
||||
line_buf = line_buf[newline_pos + 1..].to_string();
|
||||
|
||||
let line = raw_line.trim();
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
let data = &line[6..];
|
||||
if data == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
|
||||
let event: Value = match serde_json::from_str(data) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_type = event.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"message_start" => {
|
||||
if let Some(u) = event.pointer("/message/usage") {
|
||||
if let Ok(u) = serde_json::from_value::<Usage>(u.clone()) {
|
||||
usage.input_tokens = u.input_tokens;
|
||||
usage.cache_creation_input_tokens = u.cache_creation_input_tokens;
|
||||
usage.cache_read_input_tokens = u.cache_read_input_tokens;
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
if let (Some(idx), Some(block)) = (
|
||||
event.get("index").and_then(|i| i.as_u64()).map(|i| i as usize),
|
||||
event.get("content_block"),
|
||||
) {
|
||||
current_blocks.insert(idx, block.clone());
|
||||
}
|
||||
}
|
||||
"content_block_delta" => {
|
||||
let idx = event.get("index").and_then(|i| i.as_u64()).map(|i| i as usize);
|
||||
let delta = event.get("delta");
|
||||
if let (Some(idx), Some(delta)) = (idx, delta) {
|
||||
let delta_type = delta.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match delta_type {
|
||||
"text_delta" => {
|
||||
if let Some(text) = delta.get("text").and_then(|t| t.as_str()) {
|
||||
let block = current_blocks
|
||||
.entry(idx)
|
||||
.or_insert_with(|| serde_json::json!({"type": "text", "text": ""}));
|
||||
let existing = block.get("text").and_then(|t| t.as_str()).unwrap_or("");
|
||||
block["text"] = Value::String(format!("{}{}", existing, text));
|
||||
// Emit real-time text delta
|
||||
if let Some(ref tx) = stream_tx {
|
||||
let _ = tx.try_send(SDKMessage::TextDelta {
|
||||
text: text.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"input_json_delta" => {
|
||||
if let Some(partial) = delta.get("partial_json").and_then(|t| t.as_str()) {
|
||||
let block = current_blocks.entry(idx).or_insert_with(|| {
|
||||
serde_json::json!({"type": "tool_use", "id": "", "name": "", "input": {}})
|
||||
});
|
||||
let existing = block.get("_partial_json").and_then(|t| t.as_str()).unwrap_or("");
|
||||
block["_partial_json"] = Value::String(format!("{}{}", existing, partial));
|
||||
}
|
||||
}
|
||||
"thinking_delta" => {
|
||||
if let Some(thinking) = delta.get("thinking").and_then(|t| t.as_str()) {
|
||||
let block = current_blocks
|
||||
.entry(idx)
|
||||
.or_insert_with(|| serde_json::json!({"type": "thinking", "thinking": ""}));
|
||||
let existing = block.get("thinking").and_then(|t| t.as_str()).unwrap_or("");
|
||||
block["thinking"] = Value::String(format!("{}{}", existing, thinking));
|
||||
// Emit real-time thinking delta
|
||||
if let Some(ref tx) = stream_tx {
|
||||
let _ = tx.try_send(SDKMessage::ThinkingDelta {
|
||||
thinking: thinking.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"signature_delta" => {
|
||||
if let Some(sig) = delta.get("signature").and_then(|t| t.as_str()) {
|
||||
if let Some(block) = current_blocks.get_mut(&idx) {
|
||||
block["signature"] = Value::String(sig.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_stop" => {
|
||||
let idx = event.get("index").and_then(|i| i.as_u64()).map(|i| i as usize);
|
||||
if let Some(idx) = idx {
|
||||
if let Some(block) = current_blocks.remove(&idx) {
|
||||
if let Some(cb) = parse_anthropic_content_block(block) {
|
||||
content_blocks.push(cb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"message_delta" => {
|
||||
if let Some(sr) = event.pointer("/delta/stop_reason").and_then(|s| s.as_str()) {
|
||||
stop_reason = Some(sr.to_string());
|
||||
}
|
||||
if let Some(u) = event.get("usage") {
|
||||
if let Some(out) = u.get("output_tokens").and_then(|o| o.as_u64()) {
|
||||
usage.output_tokens = out;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining blocks
|
||||
let mut remaining: Vec<(usize, Value)> = current_blocks.into_iter().collect();
|
||||
remaining.sort_by_key(|(idx, _)| *idx);
|
||||
for (_, block) in remaining {
|
||||
if let Some(cb) = parse_anthropic_content_block(block) {
|
||||
content_blocks.push(cb);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProviderResponse {
|
||||
message: Message {
|
||||
role: MessageRole::Assistant,
|
||||
content: content_blocks,
|
||||
},
|
||||
usage,
|
||||
stop_reason,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_anthropic_content_block(mut block: Value) -> Option<ContentBlock> {
|
||||
let block_type = block.get("type")?.as_str()?.to_string();
|
||||
match block_type.as_str() {
|
||||
"text" => Some(ContentBlock::Text {
|
||||
text: block.get("text")?.as_str()?.to_string(),
|
||||
}),
|
||||
"tool_use" => {
|
||||
let id = block.get("id")?.as_str()?.to_string();
|
||||
let name = block.get("name")?.as_str()?.to_string();
|
||||
let input = if let Some(partial) = block.get("_partial_json").and_then(|p| p.as_str()) {
|
||||
serde_json::from_str(partial).unwrap_or(Value::Object(serde_json::Map::new()))
|
||||
} else {
|
||||
block.get("input").cloned().unwrap_or(Value::Object(serde_json::Map::new()))
|
||||
};
|
||||
if let Some(obj) = block.as_object_mut() {
|
||||
obj.remove("_partial_json");
|
||||
}
|
||||
Some(ContentBlock::ToolUse { id, name, input })
|
||||
}
|
||||
"thinking" => Some(ContentBlock::Thinking {
|
||||
thinking: block.get("thinking")?.as_str()?.to_string(),
|
||||
signature: block.get("signature").and_then(|s| s.as_str()).map(String::from),
|
||||
}),
|
||||
"image" => {
|
||||
let source = block.get("source")?;
|
||||
Some(ContentBlock::Image {
|
||||
source: ImageContentSource {
|
||||
source_type: source.get("type")?.as_str()?.to_string(),
|
||||
media_type: source.get("media_type")?.as_str()?.to_string(),
|
||||
data: source.get("data")?.as_str()?.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
use crate::types::{
|
||||
ApiToolParam, Message, SDKMessage, SystemBlock, ThinkingConfig, Usage,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::anthropic::AnthropicProvider;
|
||||
use super::openai::OpenAIProvider;
|
||||
use super::provider::{detect_api_type, ApiType, LLMProvider, ProviderRequest, ProviderResponse};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 600_000; // 10 minutes
|
||||
|
||||
/// Model configuration with context window and output limits.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelConfig {
|
||||
pub context_window: u64,
|
||||
pub max_output_tokens: u64,
|
||||
}
|
||||
|
||||
/// Get model configuration for a given model ID.
|
||||
pub fn get_model_config(model: &str) -> ModelConfig {
|
||||
match model {
|
||||
m if m.contains("opus") => ModelConfig {
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 32_000,
|
||||
},
|
||||
m if m.contains("sonnet") => ModelConfig {
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 16_000,
|
||||
},
|
||||
m if m.contains("haiku") => ModelConfig {
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 8_192,
|
||||
},
|
||||
m if m.starts_with("gpt-4o") => ModelConfig {
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 16_384,
|
||||
},
|
||||
m if m.starts_with("o1") || m.starts_with("o3") || m.starts_with("o4") => ModelConfig {
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 100_000,
|
||||
},
|
||||
m if m.contains("deepseek") => ModelConfig {
|
||||
context_window: 128_000,
|
||||
max_output_tokens: 8_192,
|
||||
},
|
||||
_ => ModelConfig {
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 16_000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Streaming event from the API (kept for backward compat).
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct StreamEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
#[serde(default)]
|
||||
pub message: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub index: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub content_block: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub delta: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub usage: Option<Usage>,
|
||||
}
|
||||
|
||||
/// Complete API response (non-streaming).
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct ApiResponse {
|
||||
pub id: String,
|
||||
pub content: Vec<Value>,
|
||||
pub model: String,
|
||||
pub stop_reason: Option<String>,
|
||||
pub usage: Usage,
|
||||
}
|
||||
|
||||
/// Error from the API.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("HTTP error: {status} - {message}")]
|
||||
HttpError { status: u16, message: String },
|
||||
|
||||
#[error("Authentication error: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
#[error("Rate limit exceeded")]
|
||||
RateLimitError,
|
||||
|
||||
#[error("Prompt too long: {0}")]
|
||||
PromptTooLong(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
NetworkError(String),
|
||||
|
||||
#[error("Parse error: {0}")]
|
||||
ParseError(String),
|
||||
|
||||
#[error("Request timeout")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// API client that auto-detects and delegates to the correct provider.
|
||||
#[derive(Clone)]
|
||||
pub struct ApiClient {
|
||||
provider: Arc<dyn LLMProvider>,
|
||||
model: String,
|
||||
api_type: ApiType,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(api_key: Option<String>, base_url: Option<String>, model: Option<String>) -> Self {
|
||||
Self::with_api_type(api_key, base_url, model, None)
|
||||
}
|
||||
|
||||
/// Create a client using a pre-built LLM provider.
|
||||
pub fn with_provider(provider: Arc<dyn LLMProvider>, model: Option<String>) -> Self {
|
||||
let api_type = provider.api_type();
|
||||
let model = model.unwrap_or_else(|| "sonnet-4-6".to_string());
|
||||
Self {
|
||||
provider,
|
||||
model,
|
||||
api_type,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client with an explicit API type override.
|
||||
pub fn with_api_type(
|
||||
api_key: Option<String>,
|
||||
base_url: Option<String>,
|
||||
model: Option<String>,
|
||||
api_type: Option<ApiType>,
|
||||
) -> Self {
|
||||
let api_key = api_key
|
||||
.or_else(|| std::env::var("CODEANY_API_KEY").ok())
|
||||
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let base_url = base_url
|
||||
.or_else(|| std::env::var("CODEANY_BASE_URL").ok())
|
||||
.or_else(|| std::env::var("ANTHROPIC_BASE_URL").ok())
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
|
||||
|
||||
let model = model
|
||||
.or_else(|| std::env::var("CODEANY_MODEL").ok())
|
||||
.or_else(|| std::env::var("ANTHROPIC_MODEL").ok())
|
||||
.unwrap_or_else(|| "sonnet-4-6".to_string());
|
||||
|
||||
let timeout_ms: u64 = std::env::var("API_TIMEOUT_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_TIMEOUT_MS);
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let mut custom_headers = HashMap::new();
|
||||
if let Ok(headers_str) = std::env::var("CODEANY_CUSTOM_HEADERS") {
|
||||
for pair in headers_str.split(',') {
|
||||
if let Some((key, value)) = pair.split_once(':') {
|
||||
custom_headers.insert(key.trim().to_string(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect API type
|
||||
let resolved_api_type = api_type.unwrap_or_else(|| detect_api_type(&model, None));
|
||||
|
||||
let provider: Arc<dyn LLMProvider> = match &resolved_api_type {
|
||||
ApiType::AnthropicMessages => Arc::new(AnthropicProvider::new(
|
||||
client,
|
||||
api_key,
|
||||
base_url,
|
||||
custom_headers,
|
||||
)),
|
||||
ApiType::OpenAICompletions => Arc::new(OpenAIProvider::new(
|
||||
client,
|
||||
api_key,
|
||||
base_url,
|
||||
custom_headers,
|
||||
)),
|
||||
};
|
||||
|
||||
Self {
|
||||
provider,
|
||||
model,
|
||||
api_type: resolved_api_type,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
pub fn set_model(&mut self, model: String) {
|
||||
self.model = model;
|
||||
}
|
||||
|
||||
pub fn api_type(&self) -> &ApiType {
|
||||
&self.api_type
|
||||
}
|
||||
|
||||
pub fn model_config(&self) -> ModelConfig {
|
||||
get_model_config(&self.model)
|
||||
}
|
||||
|
||||
/// Send a streaming request via the provider and return the parsed response.
|
||||
pub async fn create_message(
|
||||
&self,
|
||||
messages: &[Message],
|
||||
system: Option<Vec<SystemBlock>>,
|
||||
tools: Option<Vec<ApiToolParam>>,
|
||||
max_tokens: Option<u64>,
|
||||
thinking: Option<ThinkingConfig>,
|
||||
stream_tx: Option<mpsc::Sender<SDKMessage>>,
|
||||
) -> Result<ProviderResponse, ApiError> {
|
||||
let model_config = self.model_config();
|
||||
let max_tokens = max_tokens.unwrap_or(model_config.max_output_tokens);
|
||||
|
||||
let request = ProviderRequest {
|
||||
model: &self.model,
|
||||
max_tokens,
|
||||
messages,
|
||||
system,
|
||||
tools,
|
||||
thinking,
|
||||
};
|
||||
|
||||
self.provider.create_message(request, stream_tx).await
|
||||
}
|
||||
|
||||
/// Legacy method: send streaming request and return raw reqwest::Response.
|
||||
/// Delegates to the Anthropic provider only; prefer `create_message()` instead.
|
||||
pub async fn create_message_stream(
|
||||
&self,
|
||||
_messages: &[Message],
|
||||
_system: Option<Vec<SystemBlock>>,
|
||||
_tools: Option<Vec<ApiToolParam>>,
|
||||
_max_tokens: Option<u64>,
|
||||
_thinking: Option<ThinkingConfig>,
|
||||
) -> Result<reqwest::Response, ApiError> {
|
||||
// For backward compat, this method is only meaningful for direct SSE parsing.
|
||||
// New code should use create_message() which handles both providers.
|
||||
Err(ApiError::ParseError(
|
||||
"create_message_stream is deprecated; use create_message() instead".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Legacy: Parse Anthropic SSE stream. Kept for backward compatibility.
|
||||
pub async fn parse_stream(
|
||||
_response: reqwest::Response,
|
||||
) -> Result<(Message, Usage, Option<String>), ApiError> {
|
||||
// Redirect to new provider-based parsing isn't possible with raw response.
|
||||
// This method is kept only for API compat but new code should use create_message().
|
||||
Err(ApiError::ParseError(
|
||||
"parse_stream is deprecated; use create_message() instead".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if an error is retryable.
|
||||
pub fn is_retryable_error(error: &ApiError) -> bool {
|
||||
matches!(
|
||||
error,
|
||||
ApiError::RateLimitError
|
||||
| ApiError::Timeout
|
||||
| ApiError::NetworkError(_)
|
||||
| ApiError::HttpError { status: 500..=599, .. }
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if an error is an auth error.
|
||||
pub fn is_auth_error(error: &ApiError) -> bool {
|
||||
matches!(error, ApiError::AuthError(_))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod anthropic;
|
||||
mod client;
|
||||
pub mod openai;
|
||||
pub mod provider;
|
||||
|
||||
pub use client::*;
|
||||
pub use provider::{ApiType, LLMProvider, ProviderRequest, ProviderResponse};
|
||||
@@ -0,0 +1,385 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::types::{
|
||||
ContentBlock, Message, MessageRole, SDKMessage, Usage,
|
||||
};
|
||||
|
||||
use super::provider::{ApiType, LLMProvider, ProviderRequest, ProviderResponse};
|
||||
use super::ApiError;
|
||||
|
||||
// --- OpenAI request types ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAIRequest {
|
||||
model: String,
|
||||
max_tokens: u64,
|
||||
messages: Vec<OpenAIMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<OpenAITool>>,
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAIMessage {
|
||||
role: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_calls: Option<Vec<OpenAIToolCall>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_call_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAITool {
|
||||
#[serde(rename = "type")]
|
||||
tool_type: String,
|
||||
function: OpenAIFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OpenAIFunction {
|
||||
name: String,
|
||||
description: String,
|
||||
parameters: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||
struct OpenAIToolCall {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
call_type: String,
|
||||
function: OpenAIFunctionCall,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||
struct OpenAIFunctionCall {
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
/// OpenAI Chat Completions API provider.
|
||||
pub struct OpenAIProvider {
|
||||
client: Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
custom_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl OpenAIProvider {
|
||||
pub fn new(
|
||||
client: Client,
|
||||
api_key: String,
|
||||
base_url: String,
|
||||
custom_headers: HashMap<String, String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
client,
|
||||
api_key,
|
||||
base_url,
|
||||
custom_headers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LLMProvider for OpenAIProvider {
|
||||
fn api_type(&self) -> ApiType {
|
||||
ApiType::OpenAICompletions
|
||||
}
|
||||
|
||||
async fn create_message(
|
||||
&self,
|
||||
request: ProviderRequest<'_>,
|
||||
_stream_tx: Option<mpsc::Sender<SDKMessage>>,
|
||||
) -> Result<ProviderResponse, ApiError> {
|
||||
// Convert messages to OpenAI format
|
||||
let mut openai_messages = Vec::new();
|
||||
|
||||
// System prompt as first message
|
||||
if let Some(system_blocks) = &request.system {
|
||||
let system_text: String = system_blocks
|
||||
.iter()
|
||||
.map(|b| b.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
if !system_text.is_empty() {
|
||||
openai_messages.push(OpenAIMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(Value::String(system_text)),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Convert conversation messages
|
||||
for msg in request.messages {
|
||||
match msg.role {
|
||||
MessageRole::User => {
|
||||
// Check if this is a tool_result message
|
||||
let tool_results: Vec<&ContentBlock> = msg
|
||||
.content
|
||||
.iter()
|
||||
.filter(|b| matches!(b, ContentBlock::ToolResult { .. }))
|
||||
.collect();
|
||||
|
||||
if !tool_results.is_empty() {
|
||||
// Each tool_result becomes a separate "tool" role message
|
||||
for block in &tool_results {
|
||||
if let ContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
content,
|
||||
..
|
||||
} = block
|
||||
{
|
||||
let text: String = content
|
||||
.iter()
|
||||
.filter_map(|c| match c {
|
||||
crate::types::ToolResultContentBlock::Text { text } => {
|
||||
Some(text.as_str())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
openai_messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(Value::String(text)),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_use_id.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Regular user message
|
||||
let text = crate::types::extract_text(msg);
|
||||
openai_messages.push(OpenAIMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(Value::String(text)),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
MessageRole::Assistant => {
|
||||
let text = crate::types::extract_text(msg);
|
||||
let tool_uses: Vec<OpenAIToolCall> = msg
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|b| match b {
|
||||
ContentBlock::ToolUse { id, name, input } => Some(OpenAIToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: OpenAIFunctionCall {
|
||||
name: name.clone(),
|
||||
arguments: serde_json::to_string(input).unwrap_or_default(),
|
||||
},
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
openai_messages.push(OpenAIMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: if text.is_empty() { None } else { Some(Value::String(text)) },
|
||||
tool_calls: if tool_uses.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_uses)
|
||||
},
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tools to OpenAI format
|
||||
let openai_tools: Option<Vec<OpenAITool>> = request.tools.map(|tools| {
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|t| OpenAITool {
|
||||
tool_type: "function".to_string(),
|
||||
function: OpenAIFunction {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.input_schema,
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let body = OpenAIRequest {
|
||||
model: request.model.to_string(),
|
||||
max_tokens: request.max_tokens,
|
||||
messages: openai_messages,
|
||||
tools: openai_tools.filter(|t| !t.is_empty()),
|
||||
stream: true,
|
||||
};
|
||||
|
||||
let mut req_builder = self
|
||||
.client
|
||||
.post(format!("{}/v1/chat/completions", self.base_url))
|
||||
.header("authorization", format!("Bearer {}", self.api_key))
|
||||
.header("content-type", "application/json");
|
||||
|
||||
for (key, value) in &self.custom_headers {
|
||||
req_builder = req_builder.header(key, value);
|
||||
}
|
||||
|
||||
let response = req_builder.json(&body).send().await.map_err(|e| {
|
||||
if e.is_timeout() {
|
||||
ApiError::Timeout
|
||||
} else {
|
||||
ApiError::NetworkError(e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
if status == 401 {
|
||||
return Err(ApiError::AuthError("Invalid API key".to_string()));
|
||||
}
|
||||
if status == 429 {
|
||||
return Err(ApiError::RateLimitError);
|
||||
}
|
||||
if status >= 400 {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(ApiError::HttpError {
|
||||
status,
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
|
||||
parse_openai_stream(response).await
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse OpenAI SSE stream into a ProviderResponse.
|
||||
async fn parse_openai_stream(response: reqwest::Response) -> Result<ProviderResponse, ApiError> {
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ApiError::NetworkError(e.to_string()))?;
|
||||
|
||||
let mut text_content = String::new();
|
||||
let mut tool_calls: HashMap<usize, OpenAIToolCall> = HashMap::new();
|
||||
let mut usage = Usage::default();
|
||||
let mut stop_reason: Option<String> = None;
|
||||
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if !line.starts_with("data: ") {
|
||||
continue;
|
||||
}
|
||||
let data = &line[6..];
|
||||
if data == "[DONE]" {
|
||||
break;
|
||||
}
|
||||
|
||||
let chunk: Value = match serde_json::from_str(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Parse usage if present
|
||||
if let Some(u) = chunk.get("usage") {
|
||||
if let Some(pt) = u.get("prompt_tokens").and_then(|v| v.as_u64()) {
|
||||
usage.input_tokens = pt;
|
||||
}
|
||||
if let Some(ct) = u.get("completion_tokens").and_then(|v| v.as_u64()) {
|
||||
usage.output_tokens = ct;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse choices
|
||||
if let Some(choices) = chunk.get("choices").and_then(|c| c.as_array()) {
|
||||
for choice in choices {
|
||||
// Check finish_reason
|
||||
if let Some(fr) = choice.get("finish_reason").and_then(|f| f.as_str()) {
|
||||
stop_reason = Some(match fr {
|
||||
"stop" => "end_turn".to_string(),
|
||||
"tool_calls" => "tool_use".to_string(),
|
||||
"length" => "max_tokens".to_string(),
|
||||
other => other.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let delta = match choice.get("delta") {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Text content
|
||||
if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
text_content.push_str(content);
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if let Some(tcs) = delta.get("tool_calls").and_then(|t| t.as_array()) {
|
||||
for tc in tcs {
|
||||
let idx = tc.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as usize;
|
||||
|
||||
let entry = tool_calls.entry(idx).or_insert_with(|| OpenAIToolCall {
|
||||
id: String::new(),
|
||||
call_type: "function".to_string(),
|
||||
function: OpenAIFunctionCall {
|
||||
name: String::new(),
|
||||
arguments: String::new(),
|
||||
},
|
||||
});
|
||||
|
||||
if let Some(id) = tc.get("id").and_then(|i| i.as_str()) {
|
||||
entry.id = id.to_string();
|
||||
}
|
||||
if let Some(func) = tc.get("function") {
|
||||
if let Some(name) = func.get("name").and_then(|n| n.as_str()) {
|
||||
entry.function.name = name.to_string();
|
||||
}
|
||||
if let Some(args) = func.get("arguments").and_then(|a| a.as_str()) {
|
||||
entry.function.arguments.push_str(args);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build normalized content blocks
|
||||
let mut content_blocks: Vec<ContentBlock> = Vec::new();
|
||||
|
||||
if !text_content.is_empty() {
|
||||
content_blocks.push(ContentBlock::Text { text: text_content });
|
||||
}
|
||||
|
||||
// Convert tool calls to ContentBlock::ToolUse
|
||||
let mut sorted_calls: Vec<(usize, OpenAIToolCall)> = tool_calls.into_iter().collect();
|
||||
sorted_calls.sort_by_key(|(idx, _)| *idx);
|
||||
for (_, tc) in sorted_calls {
|
||||
let input: Value = serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(Value::Object(serde_json::Map::new()));
|
||||
content_blocks.push(ContentBlock::ToolUse {
|
||||
id: if tc.id.is_empty() {
|
||||
format!("call_{}", uuid::Uuid::new_v4())
|
||||
} else {
|
||||
tc.id
|
||||
},
|
||||
name: tc.function.name,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ProviderResponse {
|
||||
message: Message {
|
||||
role: MessageRole::Assistant,
|
||||
content: content_blocks,
|
||||
},
|
||||
usage,
|
||||
stop_reason,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use crate::types::{ApiToolParam, Message, SDKMessage, SystemBlock, ThinkingConfig, Usage};
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// API type identifier.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ApiType {
|
||||
AnthropicMessages,
|
||||
OpenAICompletions,
|
||||
}
|
||||
|
||||
/// Normalized response from any LLM provider.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderResponse {
|
||||
pub message: Message,
|
||||
pub usage: Usage,
|
||||
pub stop_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration passed to provider for each request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRequest<'a> {
|
||||
pub model: &'a str,
|
||||
pub max_tokens: u64,
|
||||
pub messages: &'a [Message],
|
||||
pub system: Option<Vec<SystemBlock>>,
|
||||
pub tools: Option<Vec<ApiToolParam>>,
|
||||
pub thinking: Option<ThinkingConfig>,
|
||||
}
|
||||
|
||||
/// Trait that all LLM providers must implement.
|
||||
#[async_trait]
|
||||
pub trait LLMProvider: Send + Sync {
|
||||
fn api_type(&self) -> ApiType;
|
||||
|
||||
async fn create_message(
|
||||
&self,
|
||||
request: ProviderRequest<'_>,
|
||||
stream_tx: Option<mpsc::Sender<SDKMessage>>,
|
||||
) -> Result<ProviderResponse, super::ApiError>;
|
||||
}
|
||||
|
||||
/// Detect API type from model name using heuristics (matches TS SDK).
|
||||
pub fn detect_api_type(model: &str, env_api_type: Option<&str>) -> ApiType {
|
||||
// 1. Explicit env override
|
||||
if let Some(t) = env_api_type {
|
||||
match t {
|
||||
"openai-completions" => return ApiType::OpenAICompletions,
|
||||
"anthropic-messages" => return ApiType::AnthropicMessages,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Also check CODEANY_API_TYPE env var
|
||||
if let Ok(t) = std::env::var("CODEANY_API_TYPE") {
|
||||
match t.as_str() {
|
||||
"openai-completions" => return ApiType::OpenAICompletions,
|
||||
"anthropic-messages" => return ApiType::AnthropicMessages,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Model name heuristics
|
||||
let m = model.to_lowercase();
|
||||
if m.contains("gpt-")
|
||||
|| m.starts_with("o1")
|
||||
|| m.starts_with("o3")
|
||||
|| m.starts_with("o4")
|
||||
|| m.contains("deepseek")
|
||||
|| m.contains("qwen")
|
||||
|| m.contains("yi-")
|
||||
|| m.contains("glm")
|
||||
|| m.contains("mistral")
|
||||
|| m.contains("gemma")
|
||||
|| m.contains("mimo")
|
||||
|| m.contains("llama")
|
||||
|| m.contains("gemini")
|
||||
{
|
||||
return ApiType::OpenAICompletions;
|
||||
}
|
||||
|
||||
// Default: Anthropic
|
||||
ApiType::AnthropicMessages
|
||||
}
|
||||
Reference in New Issue
Block a user