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,17 @@
|
||||
[package]
|
||||
name = "boosapi-providers"
|
||||
version = "0.0.1"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
boosapi-core = { path = "../core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "socks", "rustls-tls", "multipart"] }
|
||||
futures = "0.3"
|
||||
async-trait = "0.1"
|
||||
tracing = { workspace = true }
|
||||
tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
|
||||
base64 = "0.22"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||
@@ -0,0 +1,2 @@
|
||||
pub use crate::ProviderAdapter;
|
||||
pub use crate::ProviderRequestContext;
|
||||
@@ -0,0 +1,888 @@
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::reasoning::{resolve_reasoning, ReasoningStyle};
|
||||
use crate::{
|
||||
build_http_client, parse_base64_data_url, resolve_chat_url, resolve_models_url, ProviderAdapter,
|
||||
ProviderRequestContext,
|
||||
};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
|
||||
const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
||||
pub struct AnthropicAdapter {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl AnthropicAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: crate::build_default_http_client()
|
||||
.expect("Failed to build default HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(ctx: &ProviderRequestContext) -> String {
|
||||
ctx.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn chat_url(ctx: &ProviderRequestContext) -> String {
|
||||
resolve_chat_url(&Self::base_url(ctx), ctx.api_path.as_deref(), "/messages")
|
||||
}
|
||||
|
||||
fn get_client(&self, ctx: &ProviderRequestContext) -> Result<reqwest::Client> {
|
||||
match &ctx.proxy_config {
|
||||
Some(c) if c.proxy_type.as_deref() != Some("none") => build_http_client(Some(c)),
|
||||
_ => Ok(self.client.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal types ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicRequest {
|
||||
model: String,
|
||||
messages: Vec<AnthropicMessage>,
|
||||
max_tokens: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
top_p: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<AnthropicTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thinking: Option<AnthropicThinking>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
output_config: Option<AnthropicOutputConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicThinking {
|
||||
r#type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
budget_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicOutputConfig {
|
||||
effort: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicTool {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct AnthropicMessage {
|
||||
role: String,
|
||||
content: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicResponse {
|
||||
id: String,
|
||||
model: String,
|
||||
content: Vec<AnthropicContentBlock>,
|
||||
usage: AnthropicUsage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicContentBlock {
|
||||
#[serde(rename = "type")]
|
||||
block_type: String,
|
||||
text: Option<String>,
|
||||
thinking: Option<String>,
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicUsage {
|
||||
input_tokens: u32,
|
||||
output_tokens: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicModelsResponse {
|
||||
data: Vec<AnthropicModelInfo>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnthropicModelInfo {
|
||||
id: String,
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// Separate system messages from conversation messages and convert to Anthropic format.
|
||||
fn extract_text_content(content: &ChatContent) -> String {
|
||||
match content {
|
||||
ChatContent::Text(text) => text.clone(),
|
||||
ChatContent::Multipart(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|part| part.text.as_ref())
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> (Option<String>, Vec<AnthropicMessage>) {
|
||||
let mut system = None;
|
||||
let mut result = Vec::new();
|
||||
|
||||
for msg in messages {
|
||||
if msg.role == "system" {
|
||||
if let ChatContent::Text(text) = &msg.content {
|
||||
system = Some(text.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match msg.role.as_str() {
|
||||
"tool" => {
|
||||
// Anthropic expects tool_result as user message with content blocks
|
||||
result.push(AnthropicMessage {
|
||||
role: "user".to_string(),
|
||||
content: serde_json::json!([{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": msg.tool_call_id.as_deref().unwrap_or(""),
|
||||
"content": extract_text_content(&msg.content)
|
||||
}]),
|
||||
});
|
||||
}
|
||||
"assistant" if msg.tool_calls.is_some() => {
|
||||
let mut blocks: Vec<serde_json::Value> = Vec::new();
|
||||
let text = extract_text_content(&msg.content);
|
||||
if !text.is_empty() {
|
||||
blocks.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
}
|
||||
if let Some(ref tcs) = msg.tool_calls {
|
||||
for tc in tcs {
|
||||
let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
blocks.push(serde_json::json!({
|
||||
"type": "tool_use",
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"input": args
|
||||
}));
|
||||
}
|
||||
}
|
||||
result.push(AnthropicMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: serde_json::json!(blocks),
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let content = match &msg.content {
|
||||
ChatContent::Text(text) => serde_json::Value::String(text.clone()),
|
||||
ChatContent::Multipart(parts) => {
|
||||
let blocks: Vec<serde_json::Value> = parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if let Some(text) = &p.text {
|
||||
serde_json::json!({"type": "text", "text": text})
|
||||
} else if let Some(img) = &p.image_url {
|
||||
if let Some((media_type, data)) =
|
||||
parse_base64_data_url(&img.url)
|
||||
{
|
||||
serde_json::json!({
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": media_type,
|
||||
"data": data
|
||||
}
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"type": "image",
|
||||
"source": { "type": "url", "url": img.url }
|
||||
})
|
||||
}
|
||||
} else {
|
||||
serde_json::json!({"type": "text", "text": ""})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
serde_json::Value::Array(blocks)
|
||||
}
|
||||
};
|
||||
|
||||
result.push(AnthropicMessage {
|
||||
role: msg.role.clone(),
|
||||
content,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(system, result)
|
||||
}
|
||||
|
||||
/// Convert OpenAI-format tools to Anthropic-format tools.
|
||||
fn convert_tools_to_anthropic(tools: &Option<Vec<ChatTool>>) -> Option<Vec<AnthropicTool>> {
|
||||
tools.as_ref().map(|ts| {
|
||||
ts.iter()
|
||||
.map(|t| AnthropicTool {
|
||||
name: t.function.name.clone(),
|
||||
description: t.function.description.clone(),
|
||||
input_schema: t
|
||||
.function
|
||||
.parameters
|
||||
.clone()
|
||||
.unwrap_or(serde_json::json!({"type": "object"})),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn default_reasoning_style(model: &str) -> ReasoningStyle {
|
||||
let model = model.to_lowercase();
|
||||
if model.contains("4.6")
|
||||
|| model.contains("4-6")
|
||||
|| model.contains("4.7")
|
||||
|| model.contains("4-7")
|
||||
{
|
||||
ReasoningStyle::AnthropicAdaptive
|
||||
} else {
|
||||
ReasoningStyle::AnthropicBudgetTokens
|
||||
}
|
||||
}
|
||||
|
||||
fn build_request(
|
||||
request: &ChatRequest,
|
||||
system: Option<String>,
|
||||
messages: Vec<AnthropicMessage>,
|
||||
stream: Option<bool>,
|
||||
) -> AnthropicRequest {
|
||||
let reasoning = resolve_reasoning(request, default_reasoning_style(&request.model));
|
||||
let mut thinking = None;
|
||||
let mut output_config = None;
|
||||
let mut suppress_sampling = false;
|
||||
|
||||
if let Some(reasoning) = reasoning {
|
||||
match reasoning.style {
|
||||
ReasoningStyle::AnthropicAdaptive => {
|
||||
if matches!(reasoning.level.as_str(), "off" | "none") {
|
||||
thinking = Some(AnthropicThinking {
|
||||
r#type: "disabled".to_string(),
|
||||
budget_tokens: None,
|
||||
});
|
||||
} else {
|
||||
thinking = Some(AnthropicThinking {
|
||||
r#type: "adaptive".to_string(),
|
||||
budget_tokens: None,
|
||||
});
|
||||
output_config = reasoning
|
||||
.reasoning_effort
|
||||
.map(|effort| AnthropicOutputConfig { effort });
|
||||
suppress_sampling = reasoning.suppress_sampling_params;
|
||||
}
|
||||
}
|
||||
ReasoningStyle::AnthropicBudgetTokens => {
|
||||
if let Some(budget_tokens) = reasoning.budget_tokens {
|
||||
if budget_tokens == 0 {
|
||||
thinking = Some(AnthropicThinking {
|
||||
r#type: "disabled".to_string(),
|
||||
budget_tokens: None,
|
||||
});
|
||||
} else {
|
||||
thinking = Some(AnthropicThinking {
|
||||
r#type: "enabled".to_string(),
|
||||
budget_tokens: Some(budget_tokens),
|
||||
});
|
||||
suppress_sampling = reasoning.suppress_sampling_params;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
AnthropicRequest {
|
||||
model: request.model.clone(),
|
||||
messages,
|
||||
max_tokens: request
|
||||
.max_tokens
|
||||
.unwrap_or(if suppress_sampling { 16000 } else { 4096 }),
|
||||
system,
|
||||
temperature: if suppress_sampling {
|
||||
None
|
||||
} else {
|
||||
request.temperature
|
||||
},
|
||||
top_p: if suppress_sampling {
|
||||
None
|
||||
} else {
|
||||
request.top_p
|
||||
},
|
||||
stream,
|
||||
tools: convert_tools_to_anthropic(&request.tools),
|
||||
thinking,
|
||||
output_config,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn convert_messages_turns_data_url_images_into_base64_blocks() {
|
||||
let (_, messages) = convert_messages(&[ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Multipart(vec![
|
||||
ContentPart {
|
||||
r#type: "text".to_string(),
|
||||
text: Some("Describe this image".to_string()),
|
||||
image_url: None,
|
||||
},
|
||||
ContentPart {
|
||||
r#type: "image_url".to_string(),
|
||||
text: None,
|
||||
image_url: Some(ImageUrl {
|
||||
url: "data:image/png;base64,YWJj".to_string(),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
messages[0].content,
|
||||
json!([
|
||||
{ "type": "text", "text": "Describe this image" },
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "YWJj"
|
||||
}
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
fn request(thinking_level: Option<&str>, reasoning_profile: &str) -> ChatRequest {
|
||||
ChatRequest {
|
||||
model: "claude-opus-4-7".to_string(),
|
||||
messages: vec![ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Text("hi".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
}],
|
||||
stream: false,
|
||||
temperature: Some(0.7),
|
||||
top_p: Some(0.9),
|
||||
max_tokens: None,
|
||||
tools: None,
|
||||
thinking_budget: Some(8192),
|
||||
thinking_level: thinking_level.map(str::to_string),
|
||||
reasoning_profile: Some(reasoning_profile.to_string()),
|
||||
use_max_completion_tokens: None,
|
||||
thinking_param_style: None,
|
||||
extra_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_sends_adaptive_thinking_effort_for_claude_opus_47() {
|
||||
let req = request(Some("max"), "anthropic_adaptive");
|
||||
let (system, messages) = convert_messages(&req.messages);
|
||||
let built = build_request(&req, system, messages, Some(false));
|
||||
|
||||
let thinking = built.thinking.expect("thinking config");
|
||||
assert_eq!(thinking.r#type, "adaptive");
|
||||
assert_eq!(thinking.budget_tokens, None);
|
||||
assert_eq!(built.output_config.expect("output config").effort, "max");
|
||||
assert_eq!(built.temperature, None);
|
||||
assert_eq!(built.top_p, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_request_keeps_budget_tokens_for_vertex_compatible_claude() {
|
||||
let req = request(Some("high"), "anthropic_budget_tokens");
|
||||
let (system, messages) = convert_messages(&req.messages);
|
||||
let built = build_request(&req, system, messages, Some(false));
|
||||
|
||||
let thinking = built.thinking.expect("thinking config");
|
||||
assert_eq!(thinking.r#type, "enabled");
|
||||
assert_eq!(thinking.budget_tokens, Some(8192));
|
||||
assert!(built.output_config.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for AnthropicAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
let url = Self::chat_url(ctx);
|
||||
let (system, messages) = convert_messages(&request.messages);
|
||||
let body = build_request(&request, system, messages, None);
|
||||
|
||||
let resp = crate::apply_request_headers(
|
||||
self.get_client(ctx)?
|
||||
.post(&url)
|
||||
.header("x-api-key", &ctx.api_key)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body),
|
||||
ctx,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let s = resp.status();
|
||||
let t = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Anthropic API error {s}: {t}"
|
||||
)));
|
||||
}
|
||||
|
||||
let ar: AnthropicResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Parse error: {e}")))?;
|
||||
|
||||
let mut content = String::new();
|
||||
let mut thinking = None;
|
||||
let mut tool_calls: Vec<boosapi_core::types::ToolCall> = Vec::new();
|
||||
|
||||
for block in &ar.content {
|
||||
match block.block_type.as_str() {
|
||||
"text" => {
|
||||
if let Some(t) = &block.text {
|
||||
content.push_str(t);
|
||||
}
|
||||
}
|
||||
"thinking" => {
|
||||
if let Some(t) = &block.thinking {
|
||||
let prev = thinking.unwrap_or_default();
|
||||
thinking = Some(format!("{prev}{t}"));
|
||||
}
|
||||
}
|
||||
"tool_use" => {
|
||||
if let (Some(id), Some(name)) = (&block.id, &block.name) {
|
||||
let arguments = block
|
||||
.input
|
||||
.as_ref()
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
tool_calls.push(boosapi_core::types::ToolCall {
|
||||
id: id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: boosapi_core::types::ToolCallFunction {
|
||||
name: name.clone(),
|
||||
arguments,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChatResponse {
|
||||
id: ar.id,
|
||||
model: ar.model,
|
||||
content,
|
||||
thinking,
|
||||
usage: TokenUsage {
|
||||
prompt_tokens: ar.usage.input_tokens,
|
||||
completion_tokens: ar.usage.output_tokens,
|
||||
total_tokens: ar.usage.input_tokens + ar.usage.output_tokens,
|
||||
},
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
let client = self.get_client(ctx).unwrap_or_else(|_| self.client.clone());
|
||||
let api_key = ctx.api_key.clone();
|
||||
let custom_headers = ctx.custom_headers.clone();
|
||||
let url = Self::chat_url(ctx);
|
||||
|
||||
let (system, messages) = convert_messages(&request.messages);
|
||||
let body = build_request(&request, system, messages, Some(true));
|
||||
|
||||
let (tx, rx) = futures::channel::mpsc::unbounded();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let resp = match crate::apply_stream_headers_to_request(
|
||||
client
|
||||
.post(&url)
|
||||
.header("x-api-key", &api_key)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
.header("content-type", "application/json")
|
||||
.json(&body),
|
||||
&custom_headers,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) => {
|
||||
let s = r.status();
|
||||
let t = r.text().await.unwrap_or_default();
|
||||
let _ = tx.unbounded_send(Err(BoosApiError::Provider(format!(
|
||||
"Anthropic API error {s}: {t}"
|
||||
))));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx
|
||||
.unbounded_send(Err(BoosApiError::Provider(format!("Request failed: {e}"))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut byte_stream = resp.bytes_stream();
|
||||
let mut buf = String::new();
|
||||
|
||||
struct PendingToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
}
|
||||
let mut pending_tool_uses: Vec<PendingToolUse> = Vec::new();
|
||||
let mut current_tool_use: Option<PendingToolUse> = None;
|
||||
let mut accumulated_prompt_tokens: u32 = 0;
|
||||
let mut accumulated_completion_tokens: u32 = 0;
|
||||
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
buf.push_str(&String::from_utf8_lossy(&bytes));
|
||||
while let Some(pos) = buf.find('\n') {
|
||||
let line = buf[..pos].trim_end().to_string();
|
||||
buf = buf[pos + 1..].to_string();
|
||||
|
||||
if line.is_empty() || line.starts_with("event:") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = if let Some(d) = line.strip_prefix("data: ") {
|
||||
d
|
||||
} else if let Some(d) = line.strip_prefix("data:") {
|
||||
d
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let json: serde_json::Value = match serde_json::from_str(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_type =
|
||||
json.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
match event_type {
|
||||
"message_start" => {
|
||||
if let Some(input) = json
|
||||
.get("message")
|
||||
.and_then(|m| m.get("usage"))
|
||||
.and_then(|u| u.get("input_tokens"))
|
||||
.and_then(|v| v.as_u64())
|
||||
{
|
||||
accumulated_prompt_tokens = input as u32;
|
||||
}
|
||||
}
|
||||
"content_block_start" => {
|
||||
if let Some(cb) = json.get("content_block") {
|
||||
if cb.get("type").and_then(|t| t.as_str())
|
||||
== Some("tool_use")
|
||||
{
|
||||
let id = cb
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let name = cb
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
current_tool_use = Some(PendingToolUse {
|
||||
id,
|
||||
name,
|
||||
arguments: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = json.get("delta") {
|
||||
let dt = delta
|
||||
.get("type")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("");
|
||||
let chunk = match dt {
|
||||
"text_delta" => ChatStreamChunk {
|
||||
content: delta
|
||||
.get("text")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
thinking: None,
|
||||
done: false,
|
||||
is_final: None,
|
||||
usage: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
"thinking_delta" => ChatStreamChunk {
|
||||
content: None,
|
||||
thinking: delta
|
||||
.get("thinking")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
done: false,
|
||||
is_final: None,
|
||||
usage: None,
|
||||
tool_calls: None,
|
||||
},
|
||||
"input_json_delta" => {
|
||||
if let Some(ref mut tu) = current_tool_use {
|
||||
if let Some(partial) = delta
|
||||
.get("partial_json")
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
tu.arguments.push_str(partial);
|
||||
}
|
||||
}
|
||||
continue; // Don't send a ChatStreamChunk for JSON delta
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
let _ = tx.unbounded_send(Ok(chunk));
|
||||
}
|
||||
}
|
||||
"content_block_stop" => {
|
||||
if let Some(tu) = current_tool_use.take() {
|
||||
pending_tool_uses.push(tu);
|
||||
}
|
||||
}
|
||||
"message_delta" => {
|
||||
if let Some(u) = json.get("usage") {
|
||||
let out = u
|
||||
.get("output_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0)
|
||||
as u32;
|
||||
accumulated_completion_tokens = out;
|
||||
let _ = tx.unbounded_send(Ok(ChatStreamChunk {
|
||||
content: None,
|
||||
thinking: None,
|
||||
done: false,
|
||||
is_final: None,
|
||||
usage: Some(TokenUsage {
|
||||
prompt_tokens: accumulated_prompt_tokens,
|
||||
completion_tokens: out,
|
||||
total_tokens: accumulated_prompt_tokens + out,
|
||||
}),
|
||||
tool_calls: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
"message_stop" => {
|
||||
let tool_calls = if pending_tool_uses.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
pending_tool_uses
|
||||
.iter()
|
||||
.map(|tu| boosapi_core::types::ToolCall {
|
||||
id: tu.id.clone(),
|
||||
call_type: "function".to_string(),
|
||||
function: boosapi_core::types::ToolCallFunction {
|
||||
name: tu.name.clone(),
|
||||
arguments: tu.arguments.clone(),
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
let final_usage = if accumulated_prompt_tokens > 0
|
||||
|| accumulated_completion_tokens > 0
|
||||
{
|
||||
Some(TokenUsage {
|
||||
prompt_tokens: accumulated_prompt_tokens,
|
||||
completion_tokens: accumulated_completion_tokens,
|
||||
total_tokens: accumulated_prompt_tokens
|
||||
+ accumulated_completion_tokens,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = tx.unbounded_send(Ok(ChatStreamChunk {
|
||||
content: None,
|
||||
thinking: None,
|
||||
done: true,
|
||||
is_final: None,
|
||||
usage: final_usage,
|
||||
tool_calls,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.unbounded_send(Err(BoosApiError::Provider(format!(
|
||||
"Stream error: {e}"
|
||||
))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.unbounded_send(Ok(ChatStreamChunk {
|
||||
content: None,
|
||||
thinking: None,
|
||||
done: true,
|
||||
is_final: None,
|
||||
usage: None,
|
||||
tool_calls: None,
|
||||
}));
|
||||
});
|
||||
|
||||
Box::pin(rx)
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
let url = resolve_models_url(&Self::base_url(ctx));
|
||||
|
||||
let resp = crate::apply_request_headers(
|
||||
self.get_client(ctx)?
|
||||
.get(&url)
|
||||
.header("x-api-key", &ctx.api_key)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION),
|
||||
ctx,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let s = resp.status();
|
||||
let t = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Anthropic API error {s}: {t}"
|
||||
)));
|
||||
}
|
||||
|
||||
let models: AnthropicModelsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Parse error: {e}")))?;
|
||||
|
||||
Ok(models
|
||||
.data
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let model_type = ModelType::detect(&m.id);
|
||||
let mut caps = match model_type {
|
||||
ModelType::Chat => vec![ModelCapability::TextChat],
|
||||
ModelType::Embedding => vec![],
|
||||
ModelType::Image => vec![],
|
||||
ModelType::Rerank => vec![],
|
||||
ModelType::Voice => vec![ModelCapability::RealtimeVoice],
|
||||
};
|
||||
let id_lower = m.id.to_lowercase();
|
||||
if id_lower.contains("claude") && !id_lower.contains("haiku") {
|
||||
caps.push(ModelCapability::Vision);
|
||||
}
|
||||
if id_lower.contains("opus")
|
||||
|| id_lower.contains("sonnet-4")
|
||||
|| id_lower.contains("3-7")
|
||||
|| id_lower.contains("3.7")
|
||||
{
|
||||
caps.push(ModelCapability::Reasoning);
|
||||
}
|
||||
let name = m.display_name.unwrap_or_else(|| m.id.clone());
|
||||
Model {
|
||||
provider_id: ctx.provider_id.clone(),
|
||||
model_id: m.id,
|
||||
name,
|
||||
group_name: None,
|
||||
model_type,
|
||||
capabilities: caps,
|
||||
max_tokens: None,
|
||||
enabled: true,
|
||||
param_overrides: None,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
// Try list_models first (works with official Anthropic API)
|
||||
if self.list_models(ctx).await.is_ok() {
|
||||
return Ok(true);
|
||||
}
|
||||
// Fallback: probe the /messages endpoint with an empty body.
|
||||
// Valid key → 400 (bad request); invalid key → 401/403.
|
||||
// This avoids token consumption and works with proxy services
|
||||
// that don't support the /models endpoint.
|
||||
let url = Self::chat_url(ctx);
|
||||
let resp = crate::apply_request_headers(
|
||||
self.get_client(ctx)?
|
||||
.post(&url)
|
||||
.header("x-api-key", &ctx.api_key)
|
||||
.header("anthropic-version", ANTHROPIC_VERSION)
|
||||
.header("content-type", "application/json")
|
||||
.body("{}"),
|
||||
ctx,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Validation request failed: {e}")))?;
|
||||
let status = resp.status().as_u16();
|
||||
Ok(status != 401 && status != 403)
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
Err(BoosApiError::Provider("Anthropic does not support embedding API. Please use an OpenAI-compatible or Gemini provider for embeddings.".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::{Stream, stream};
|
||||
use serde::Deserialize;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::{ProviderAdapter, ProviderRequestContext, build_http_client};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.cohere.com/v2";
|
||||
|
||||
pub struct CohereAdapter {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl CohereAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: crate::build_default_http_client()
|
||||
.expect("Failed to build default HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(ctx: &ProviderRequestContext) -> String {
|
||||
ctx.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn get_client(&self, ctx: &ProviderRequestContext) -> Result<reqwest::Client> {
|
||||
match &ctx.proxy_config {
|
||||
Some(c) if c.proxy_type.as_deref() != Some("none") => build_http_client(Some(c)),
|
||||
_ => Ok(self.client.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported<T>() -> Result<T> {
|
||||
Err(BoosApiError::Provider(
|
||||
"Cohere provider only supports rerank in BoosApi".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn cohere_models(provider_id: &str) -> Vec<Model> {
|
||||
["rerank-v4.0-pro", "rerank-v4.0-fast", "rerank-v3.5"]
|
||||
.into_iter()
|
||||
.map(|model_id| Model {
|
||||
provider_id: provider_id.to_string(),
|
||||
model_id: model_id.to_string(),
|
||||
name: model_id.to_string(),
|
||||
group_name: None,
|
||||
model_type: ModelType::Rerank,
|
||||
capabilities: vec![],
|
||||
max_tokens: None,
|
||||
enabled: true,
|
||||
param_overrides: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn build_cohere_rerank_body(request: &RerankRequest) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"model": request.model,
|
||||
"query": request.query,
|
||||
"documents": request.documents,
|
||||
"top_n": request.top_n,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NativeRerankResponse {
|
||||
results: Vec<NativeRerankResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NativeRerankResult {
|
||||
index: usize,
|
||||
relevance_score: f32,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_cohere_rerank_response(body: &str) -> Result<RerankResponse> {
|
||||
let parsed: NativeRerankResponse = serde_json::from_str(body)
|
||||
.map_err(|e| BoosApiError::Provider(format!("Cohere rerank parse error: {e}")))?;
|
||||
Ok(RerankResponse {
|
||||
results: parsed
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|r| RerankResult {
|
||||
index: r.index,
|
||||
relevance_score: r.relevance_score,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for CohereAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
unsupported()
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
Box::pin(stream::once(async {
|
||||
Err(BoosApiError::Provider(
|
||||
"Cohere provider only supports rerank in BoosApi".into(),
|
||||
))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
Ok(cohere_models(&ctx.provider_id))
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
unsupported()
|
||||
}
|
||||
|
||||
async fn rerank(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: RerankRequest,
|
||||
) -> Result<RerankResponse> {
|
||||
let url = format!("{}/rerank", Self::base_url(ctx));
|
||||
let resp = crate::apply_request_headers(
|
||||
self.get_client(ctx)?
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", ctx.api_key))
|
||||
.json(&build_cohere_rerank_body(&request)),
|
||||
ctx,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Cohere rerank request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Cohere rerank API error {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Cohere rerank body error: {e}")))?;
|
||||
parse_cohere_rerank_response(&text)
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
let request = RerankRequest {
|
||||
model: "rerank-v4.0-fast".to_string(),
|
||||
query: "test".to_string(),
|
||||
documents: vec!["test".to_string()],
|
||||
top_n: 1,
|
||||
};
|
||||
self.rerank(ctx, request).await.map(|_| true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cohere_body_uses_native_top_n() {
|
||||
let body = build_cohere_rerank_body(&RerankRequest {
|
||||
model: "rerank-v4.0-pro".into(),
|
||||
query: "capital".into(),
|
||||
documents: vec!["doc".into()],
|
||||
top_n: 3,
|
||||
});
|
||||
|
||||
assert_eq!(body["model"], "rerank-v4.0-pro");
|
||||
assert_eq!(body["query"], "capital");
|
||||
assert_eq!(body["documents"][0], "doc");
|
||||
assert_eq!(body["top_n"], 3);
|
||||
assert!(body.get("top_k").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cohere_parser_reads_relevance_score() {
|
||||
let parsed =
|
||||
parse_cohere_rerank_response(r#"{"results":[{"index":2,"relevance_score":0.91}]}"#)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
RerankResponse {
|
||||
results: vec![RerankResult {
|
||||
index: 2,
|
||||
relevance_score: 0.91,
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use boosapi_core::error::Result;
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::openai::OpenAIPolicy;
|
||||
use crate::openai_compat::OpenAICompatAdapter;
|
||||
use crate::{ProviderAdapter, ProviderRequestContext};
|
||||
|
||||
pub struct CustomOpenAIAdapter {
|
||||
inner: OpenAICompatAdapter<OpenAIPolicy>,
|
||||
}
|
||||
|
||||
impl CustomOpenAIAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: OpenAICompatAdapter::new(OpenAIPolicy),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for CustomOpenAIAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
self.inner.chat(ctx, request).await
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
self.inner.chat_stream(ctx, request)
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
self.inner.list_models(ctx).await
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
self.inner.embed(ctx, request).await
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
self.inner.validate_key(ctx).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use boosapi_core::error::Result;
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde_json::{Map, Value};
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::openai_compat::{OpenAICompatAdapter, OpenAICompatPolicy};
|
||||
use crate::reasoning::{ReasoningStyle, ResolvedReasoning};
|
||||
use crate::{ProviderAdapter, ProviderRequestContext};
|
||||
|
||||
pub struct DeepSeekAdapter {
|
||||
inner: OpenAICompatAdapter<DeepSeekPolicy>,
|
||||
}
|
||||
|
||||
const DEEPSEEK_V4_MAX_COMPLETION_TOKENS: u32 = 262_144;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct DeepSeekPolicy;
|
||||
|
||||
pub(crate) fn deepseek_compat_max_completion_tokens_cap(request: &ChatRequest) -> Option<u32> {
|
||||
request
|
||||
.model
|
||||
.to_ascii_lowercase()
|
||||
.contains("deepseek-v4")
|
||||
.then_some(DEEPSEEK_V4_MAX_COMPLETION_TOKENS)
|
||||
}
|
||||
|
||||
impl OpenAICompatPolicy for DeepSeekPolicy {
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.deepseek.com/v1"
|
||||
}
|
||||
|
||||
fn error_label(&self) -> &'static str {
|
||||
"DeepSeek API"
|
||||
}
|
||||
|
||||
fn default_reasoning_style(&self, _request: &ChatRequest) -> ReasoningStyle {
|
||||
ReasoningStyle::OpenAIReasoningEffort
|
||||
}
|
||||
|
||||
fn normalize_reasoning_effort(
|
||||
&self,
|
||||
_request: &ChatRequest,
|
||||
level: &str,
|
||||
effort: String,
|
||||
) -> Option<String> {
|
||||
if matches!(level, "off" | "none") {
|
||||
return None;
|
||||
}
|
||||
match effort.as_str() {
|
||||
"minimal" | "low" | "medium" | "high" => Some("high".to_string()),
|
||||
"xhigh" | "max" => Some("max".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn use_max_completion_tokens(&self, _request: &ChatRequest) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn max_completion_tokens_cap(&self, request: &ChatRequest) -> Option<u32> {
|
||||
deepseek_compat_max_completion_tokens_cap(request)
|
||||
}
|
||||
|
||||
fn extra_body_fields(&self, reasoning: Option<&ResolvedReasoning>) -> Map<String, Value> {
|
||||
let mut extra = Map::new();
|
||||
let Some(reasoning) = reasoning else {
|
||||
return extra;
|
||||
};
|
||||
|
||||
let thinking_type = if matches!(reasoning.level.as_str(), "off" | "none") {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
};
|
||||
extra.insert(
|
||||
"thinking".to_string(),
|
||||
serde_json::json!({ "type": thinking_type }),
|
||||
);
|
||||
extra
|
||||
}
|
||||
|
||||
fn include_assistant_reasoning_content(
|
||||
&self,
|
||||
messages: &[ChatMessage],
|
||||
tools: &Option<Vec<ChatTool>>,
|
||||
) -> bool {
|
||||
tools.as_ref().is_some_and(|tools| !tools.is_empty())
|
||||
&& messages
|
||||
.iter()
|
||||
.any(|msg| msg.role == "assistant" && msg.reasoning_content.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
impl DeepSeekAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: OpenAICompatAdapter::new(DeepSeekPolicy),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for DeepSeekAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
self.inner.chat(ctx, request).await
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
self.inner.chat_stream(ctx, request)
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
self.inner.list_models(ctx).await
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
self.inner.embed(ctx, request).await
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
self.inner.validate_key(ctx).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::reasoning::{resolve_reasoning, ReasoningStyle};
|
||||
use crate::{build_http_client, parse_base64_data_url, ProviderAdapter, ProviderRequestContext};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
pub struct GeminiAdapter {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl GeminiAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: crate::build_default_http_client()
|
||||
.expect("Failed to build default HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(ctx: &ProviderRequestContext) -> String {
|
||||
ctx.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn get_client(&self, ctx: &ProviderRequestContext) -> Result<reqwest::Client> {
|
||||
match &ctx.proxy_config {
|
||||
Some(c) if c.proxy_type.as_deref() != Some("none") => build_http_client(Some(c)),
|
||||
_ => Ok(self.client.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internal types ---
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiRequest {
|
||||
contents: Vec<GeminiContent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
system_instruction: Option<GeminiContent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
generation_config: Option<GeminiGenerationConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tools: Option<Vec<GeminiToolDeclaration>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct GeminiContent {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
role: Option<String>,
|
||||
parts: Vec<GeminiPart>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiPart {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
inline_data: Option<GeminiInlineData>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
function_call: Option<GeminiFunctionCall>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
function_response: Option<GeminiFunctionResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiFunctionCall {
|
||||
name: String,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiFunctionResponse {
|
||||
name: String,
|
||||
response: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiInlineData {
|
||||
mime_type: String,
|
||||
data: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiGenerationConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
top_p: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
max_output_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thinking_config: Option<GeminiThinkingConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiThinkingConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thinking_budget: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thinking_level: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiResponse {
|
||||
candidates: Option<Vec<GeminiCandidate>>,
|
||||
usage_metadata: Option<GeminiUsageMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GeminiCandidate {
|
||||
content: Option<GeminiContent>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiUsageMetadata {
|
||||
prompt_token_count: Option<u32>,
|
||||
candidates_token_count: Option<u32>,
|
||||
total_token_count: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiModelsResponse {
|
||||
models: Option<Vec<GeminiModel>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiModel {
|
||||
name: String,
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiToolDeclaration {
|
||||
function_declarations: Vec<GeminiFunctionDeclaration>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GeminiFunctionDeclaration {
|
||||
name: String,
|
||||
description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
parameters: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn convert_tools_to_gemini(tools: &Option<Vec<ChatTool>>) -> Option<Vec<GeminiToolDeclaration>> {
|
||||
tools.as_ref().map(|ts| {
|
||||
vec![GeminiToolDeclaration {
|
||||
function_declarations: ts
|
||||
.iter()
|
||||
.map(|t| GeminiFunctionDeclaration {
|
||||
name: t.function.name.clone(),
|
||||
description: t.function.description.clone().unwrap_or_default(),
|
||||
parameters: t.function.parameters.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_text_content(content: &ChatContent) -> String {
|
||||
match content {
|
||||
ChatContent::Text(text) => text.clone(),
|
||||
ChatContent::Multipart(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|part| part.text.as_ref())
|
||||
.cloned()
|
||||
.collect::<Vec<String>>()
|
||||
.join(" "),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_messages(messages: &[ChatMessage]) -> (Option<GeminiContent>, Vec<GeminiContent>) {
|
||||
let mut system_instruction = None;
|
||||
let mut contents = Vec::new();
|
||||
|
||||
// Pre-build a map from tool_call_id to function name for Gemini's functionResponse
|
||||
let mut tool_id_to_name: std::collections::HashMap<String, String> =
|
||||
std::collections::HashMap::new();
|
||||
for msg in messages {
|
||||
if let Some(ref tcs) = msg.tool_calls {
|
||||
for tc in tcs {
|
||||
tool_id_to_name.insert(tc.id.clone(), tc.function.name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for msg in messages {
|
||||
if msg.role == "system" {
|
||||
let parts = match &msg.content {
|
||||
ChatContent::Text(text) => vec![GeminiPart {
|
||||
text: Some(text.clone()),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
}],
|
||||
ChatContent::Multipart(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
if let Some(text) = &p.text {
|
||||
Some(GeminiPart {
|
||||
text: Some(text.clone()),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
})
|
||||
} else if let Some(img) = &p.image_url {
|
||||
parse_base64_data_url(&img.url).map(|(mime_type, data)| GeminiPart {
|
||||
text: None,
|
||||
inline_data: Some(GeminiInlineData { mime_type, data }),
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
system_instruction = Some(GeminiContent { role: None, parts });
|
||||
continue;
|
||||
}
|
||||
|
||||
match msg.role.as_str() {
|
||||
"tool" => {
|
||||
// Gemini needs the function NAME, not the call ID
|
||||
// Look up the actual name from the tool_call_id → name map
|
||||
let tool_name = msg
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.and_then(|id| tool_id_to_name.get(id).map(|s| s.as_str()))
|
||||
.unwrap_or("unknown");
|
||||
let result_value: serde_json::Value = serde_json::from_str(&extract_text_content(
|
||||
&msg.content,
|
||||
))
|
||||
.unwrap_or(serde_json::json!({ "result": extract_text_content(&msg.content) }));
|
||||
contents.push(GeminiContent {
|
||||
role: Some("user".to_string()),
|
||||
parts: vec![GeminiPart {
|
||||
text: None,
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: Some(GeminiFunctionResponse {
|
||||
name: tool_name.to_string(),
|
||||
response: result_value,
|
||||
}),
|
||||
}],
|
||||
});
|
||||
}
|
||||
"assistant" if msg.tool_calls.is_some() => {
|
||||
let mut parts = Vec::new();
|
||||
let text = extract_text_content(&msg.content);
|
||||
if !text.is_empty() {
|
||||
parts.push(GeminiPart {
|
||||
text: Some(text),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
});
|
||||
}
|
||||
if let Some(ref tcs) = msg.tool_calls {
|
||||
for tc in tcs {
|
||||
let args: serde_json::Value = serde_json::from_str(&tc.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
|
||||
parts.push(GeminiPart {
|
||||
text: None,
|
||||
inline_data: None,
|
||||
function_call: Some(GeminiFunctionCall {
|
||||
name: tc.function.name.clone(),
|
||||
args,
|
||||
}),
|
||||
function_response: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
contents.push(GeminiContent {
|
||||
role: Some("model".to_string()),
|
||||
parts,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let parts = match &msg.content {
|
||||
ChatContent::Text(text) => vec![GeminiPart {
|
||||
text: Some(text.clone()),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
}],
|
||||
ChatContent::Multipart(parts) => parts
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
if let Some(text) = &p.text {
|
||||
Some(GeminiPart {
|
||||
text: Some(text.clone()),
|
||||
inline_data: None,
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
})
|
||||
} else if let Some(img) = &p.image_url {
|
||||
parse_base64_data_url(&img.url).map(|(mime_type, data)| {
|
||||
GeminiPart {
|
||||
text: None,
|
||||
inline_data: Some(GeminiInlineData { mime_type, data }),
|
||||
function_call: None,
|
||||
function_response: None,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let role = match msg.role.as_str() {
|
||||
"assistant" => "model",
|
||||
other => other,
|
||||
};
|
||||
|
||||
contents.push(GeminiContent {
|
||||
role: Some(role.to_string()),
|
||||
parts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(system_instruction, contents)
|
||||
}
|
||||
|
||||
fn make_gen_config(request: &ChatRequest) -> Option<GeminiGenerationConfig> {
|
||||
let model_id = request.model.to_lowercase();
|
||||
let default_style =
|
||||
if model_id.contains("3.") || model_id.contains("gemini-3") || model_id.contains("-3-") {
|
||||
ReasoningStyle::GeminiThinkingLevel
|
||||
} else {
|
||||
ReasoningStyle::GeminiThinkingBudget
|
||||
};
|
||||
let thinking_config = resolve_reasoning(request, default_style).map(|r| GeminiThinkingConfig {
|
||||
thinking_budget: r.budget_tokens,
|
||||
thinking_level: r.thinking_level,
|
||||
});
|
||||
if request.temperature.is_some()
|
||||
|| request.top_p.is_some()
|
||||
|| request.max_tokens.is_some()
|
||||
|| thinking_config.is_some()
|
||||
{
|
||||
Some(GeminiGenerationConfig {
|
||||
temperature: request.temperature,
|
||||
top_p: request.top_p,
|
||||
max_output_tokens: request.max_tokens,
|
||||
thinking_config,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn usage_from_meta(meta: Option<GeminiUsageMetadata>) -> TokenUsage {
|
||||
meta.map(|u| TokenUsage {
|
||||
prompt_tokens: u.prompt_token_count.unwrap_or(0),
|
||||
completion_tokens: u.candidates_token_count.unwrap_or(0),
|
||||
total_tokens: u.total_token_count.unwrap_or(0),
|
||||
})
|
||||
.unwrap_or(TokenUsage {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn convert_messages_keeps_inline_image_parts() {
|
||||
let (_, contents) = convert_messages(&[ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Multipart(vec![
|
||||
ContentPart {
|
||||
r#type: "text".to_string(),
|
||||
text: Some("Describe this image".to_string()),
|
||||
image_url: None,
|
||||
},
|
||||
ContentPart {
|
||||
r#type: "image_url".to_string(),
|
||||
text: None,
|
||||
image_url: Some(ImageUrl {
|
||||
url: "data:image/png;base64,YWJj".to_string(),
|
||||
}),
|
||||
},
|
||||
]),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
}]);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&contents[0]).unwrap(),
|
||||
json!({
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{ "text": "Describe this image" },
|
||||
{
|
||||
"inlineData": {
|
||||
"mimeType": "image/png",
|
||||
"data": "YWJj"
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn request(
|
||||
model: &str,
|
||||
thinking_level: Option<&str>,
|
||||
thinking_budget: Option<u32>,
|
||||
) -> ChatRequest {
|
||||
ChatRequest {
|
||||
model: model.to_string(),
|
||||
messages: vec![ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Text("hi".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
}],
|
||||
stream: false,
|
||||
temperature: Some(0.7),
|
||||
top_p: None,
|
||||
max_tokens: None,
|
||||
tools: None,
|
||||
thinking_budget,
|
||||
thinking_level: thinking_level.map(str::to_string),
|
||||
reasoning_profile: Some("gemini_thinking_level".to_string()),
|
||||
use_max_completion_tokens: None,
|
||||
thinking_param_style: None,
|
||||
extra_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_gen_config_sends_thinking_level_for_gemini_3() {
|
||||
let config = make_gen_config(&request("gemini-3.1-flash", Some("minimal"), Some(8192)))
|
||||
.expect("generation config");
|
||||
let thinking = config.thinking_config.expect("thinking config");
|
||||
|
||||
assert_eq!(thinking.thinking_level.as_deref(), Some("minimal"));
|
||||
assert_eq!(thinking.thinking_budget, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_id() -> String {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
format!("gemini-{ts:x}")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for GeminiAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
let base_url = Self::base_url(ctx);
|
||||
let url = format!(
|
||||
"{}/models/{}:generateContent?key={}",
|
||||
base_url, request.model, ctx.api_key
|
||||
);
|
||||
|
||||
let (system_instruction, contents) = convert_messages(&request.messages);
|
||||
let body = GeminiRequest {
|
||||
contents,
|
||||
system_instruction,
|
||||
generation_config: make_gen_config(&request),
|
||||
tools: convert_tools_to_gemini(&request.tools),
|
||||
};
|
||||
|
||||
let resp = crate::apply_request_headers(self.get_client(ctx)?.post(&url).json(&body), ctx)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let s = resp.status();
|
||||
let t = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!("Gemini API error {s}: {t}")));
|
||||
}
|
||||
|
||||
let gr: GeminiResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Parse error: {e}")))?;
|
||||
|
||||
let parts = gr
|
||||
.candidates
|
||||
.as_ref()
|
||||
.and_then(|c| c.first())
|
||||
.and_then(|c| c.content.as_ref())
|
||||
.map(|c| &c.parts);
|
||||
|
||||
let mut content = String::new();
|
||||
let mut tool_calls: Vec<boosapi_core::types::ToolCall> = Vec::new();
|
||||
|
||||
if let Some(parts) = parts {
|
||||
for part in parts {
|
||||
if let Some(ref text) = part.text {
|
||||
content.push_str(text);
|
||||
}
|
||||
if let Some(ref fc) = part.function_call {
|
||||
tool_calls.push(boosapi_core::types::ToolCall {
|
||||
id: format!(
|
||||
"gemini_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
call_type: "function".to_string(),
|
||||
function: boosapi_core::types::ToolCallFunction {
|
||||
name: fc.name.clone(),
|
||||
arguments: serde_json::to_string(&fc.args).unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChatResponse {
|
||||
id: simple_id(),
|
||||
model: request.model,
|
||||
content,
|
||||
thinking: None,
|
||||
usage: usage_from_meta(gr.usage_metadata),
|
||||
tool_calls: if tool_calls.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
let client = self.get_client(ctx).unwrap_or_else(|_| self.client.clone());
|
||||
let api_key = ctx.api_key.clone();
|
||||
let custom_headers = ctx.custom_headers.clone();
|
||||
let base_url = Self::base_url(ctx);
|
||||
let url = format!(
|
||||
"{}/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||
base_url, request.model, api_key
|
||||
);
|
||||
|
||||
let (system_instruction, contents) = convert_messages(&request.messages);
|
||||
let body = GeminiRequest {
|
||||
contents,
|
||||
system_instruction,
|
||||
generation_config: make_gen_config(&request),
|
||||
tools: convert_tools_to_gemini(&request.tools),
|
||||
};
|
||||
|
||||
let (tx, rx) = futures::channel::mpsc::unbounded();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let resp = match crate::apply_stream_headers_to_request(
|
||||
client.post(&url).json(&body),
|
||||
&custom_headers,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) if r.status().is_success() => r,
|
||||
Ok(r) => {
|
||||
let s = r.status();
|
||||
let t = r.text().await.unwrap_or_default();
|
||||
let _ = tx.unbounded_send(Err(BoosApiError::Provider(format!(
|
||||
"Gemini API error {s}: {t}"
|
||||
))));
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx
|
||||
.unbounded_send(Err(BoosApiError::Provider(format!("Request failed: {e}"))));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut byte_stream = resp.bytes_stream();
|
||||
let mut buf = String::new();
|
||||
|
||||
while let Some(chunk) = byte_stream.next().await {
|
||||
match chunk {
|
||||
Ok(bytes) => {
|
||||
buf.push_str(&String::from_utf8_lossy(&bytes));
|
||||
while let Some(pos) = buf.find('\n') {
|
||||
let line = buf[..pos].trim_end().to_string();
|
||||
buf = buf[pos + 1..].to_string();
|
||||
|
||||
if line.is_empty() || line.starts_with("event:") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = if let Some(d) = line.strip_prefix("data: ") {
|
||||
d
|
||||
} else if let Some(d) = line.strip_prefix("data:") {
|
||||
d
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Ok(gr) = serde_json::from_str::<GeminiResponse>(data) {
|
||||
let parts = gr
|
||||
.candidates
|
||||
.as_ref()
|
||||
.and_then(|c| c.first())
|
||||
.and_then(|c| c.content.as_ref())
|
||||
.map(|c| &c.parts);
|
||||
|
||||
let mut content: Option<String> = None;
|
||||
let mut tool_calls_vec: Vec<boosapi_core::types::ToolCall> =
|
||||
Vec::new();
|
||||
|
||||
if let Some(parts) = parts {
|
||||
for part in parts {
|
||||
if let Some(ref text) = part.text {
|
||||
content = Some(text.clone());
|
||||
}
|
||||
if let Some(ref fc) = part.function_call {
|
||||
tool_calls_vec.push(boosapi_core::types::ToolCall {
|
||||
id: format!(
|
||||
"gemini_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
),
|
||||
call_type: "function".to_string(),
|
||||
function: boosapi_core::types::ToolCallFunction {
|
||||
name: fc.name.clone(),
|
||||
arguments: serde_json::to_string(&fc.args)
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tool_calls = if tool_calls_vec.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(tool_calls_vec)
|
||||
};
|
||||
|
||||
let usage = gr.usage_metadata.map(|u| TokenUsage {
|
||||
prompt_tokens: u.prompt_token_count.unwrap_or(0),
|
||||
completion_tokens: u.candidates_token_count.unwrap_or(0),
|
||||
total_tokens: u.total_token_count.unwrap_or(0),
|
||||
});
|
||||
|
||||
let _ = tx.unbounded_send(Ok(ChatStreamChunk {
|
||||
content,
|
||||
thinking: None,
|
||||
done: false,
|
||||
is_final: None,
|
||||
usage,
|
||||
tool_calls,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.unbounded_send(Err(BoosApiError::Provider(format!(
|
||||
"Stream error: {e}"
|
||||
))));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = tx.unbounded_send(Ok(ChatStreamChunk {
|
||||
content: None,
|
||||
thinking: None,
|
||||
done: true,
|
||||
is_final: None,
|
||||
usage: None,
|
||||
tool_calls: None,
|
||||
}));
|
||||
});
|
||||
|
||||
Box::pin(rx)
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
let url = format!("{}?key={}", crate::resolve_models_url(&Self::base_url(ctx)), ctx.api_key);
|
||||
|
||||
let resp = crate::apply_request_headers(self.get_client(ctx)?.get(&url), ctx)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let s = resp.status();
|
||||
let t = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!("Gemini API error {s}: {t}")));
|
||||
}
|
||||
|
||||
let models: GeminiModelsResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Parse error: {e}")))?;
|
||||
|
||||
Ok(models
|
||||
.models
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| {
|
||||
let model_id = m
|
||||
.name
|
||||
.strip_prefix("models/")
|
||||
.unwrap_or(&m.name)
|
||||
.to_string();
|
||||
let name = m.display_name.unwrap_or_else(|| model_id.clone());
|
||||
let model_type = ModelType::detect(&model_id);
|
||||
let mut caps = match model_type {
|
||||
ModelType::Chat => vec![ModelCapability::TextChat],
|
||||
ModelType::Embedding => vec![],
|
||||
ModelType::Image => vec![],
|
||||
ModelType::Rerank => vec![],
|
||||
ModelType::Voice => vec![ModelCapability::RealtimeVoice],
|
||||
};
|
||||
if model_id.contains("pro") || model_id.contains("flash") {
|
||||
caps.push(ModelCapability::Vision);
|
||||
}
|
||||
Model {
|
||||
provider_id: ctx.provider_id.clone(),
|
||||
model_id: model_id.clone(),
|
||||
name,
|
||||
group_name: None,
|
||||
model_type,
|
||||
capabilities: caps,
|
||||
max_tokens: None,
|
||||
enabled: true,
|
||||
param_overrides: None,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
let base_url = Self::base_url(ctx);
|
||||
let url = format!(
|
||||
"{}/models/{}:batchEmbedContents?key={}",
|
||||
base_url, request.model, ctx.api_key
|
||||
);
|
||||
|
||||
let requests: Vec<serde_json::Value> = request
|
||||
.input
|
||||
.iter()
|
||||
.map(|text| {
|
||||
let mut req = serde_json::json!({
|
||||
"model": format!("models/{}", request.model),
|
||||
"content": { "parts": [{ "text": text }] }
|
||||
});
|
||||
if let Some(dims) = request.dimensions {
|
||||
req["outputDimensionality"] = serde_json::json!(dims);
|
||||
}
|
||||
req
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = serde_json::json!({ "requests": requests });
|
||||
|
||||
let resp = crate::apply_request_headers(self.get_client(ctx)?.post(&url).json(&body), ctx)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Gemini embed request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let s = resp.status();
|
||||
let t = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Gemini embed API error {s}: {t}"
|
||||
)));
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GeminiBatchEmbedResponse {
|
||||
embeddings: Vec<GeminiEmbedValues>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct GeminiEmbedValues {
|
||||
values: Vec<f32>,
|
||||
}
|
||||
|
||||
let result: GeminiBatchEmbedResponse = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Gemini embed parse error: {e}")))?;
|
||||
|
||||
let dimensions = result
|
||||
.embeddings
|
||||
.first()
|
||||
.map(|e| e.values.len())
|
||||
.unwrap_or(0);
|
||||
let embeddings: Vec<Vec<f32>> = result.embeddings.into_iter().map(|e| e.values).collect();
|
||||
|
||||
Ok(EmbedResponse {
|
||||
embeddings,
|
||||
dimensions,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use boosapi_core::error::Result;
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde_json::{Map, Value};
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::openai_compat::{OpenAICompatAdapter, OpenAICompatPolicy};
|
||||
use crate::reasoning::{ReasoningStyle, ResolvedReasoning};
|
||||
use crate::{ProviderAdapter, ProviderRequestContext};
|
||||
|
||||
pub struct GLMAdapter {
|
||||
inner: OpenAICompatAdapter<GLMPolicy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct GLMPolicy;
|
||||
|
||||
impl OpenAICompatPolicy for GLMPolicy {
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
|
||||
fn error_label(&self) -> &'static str {
|
||||
"GLM API"
|
||||
}
|
||||
|
||||
fn default_reasoning_style(&self, _request: &ChatRequest) -> ReasoningStyle {
|
||||
ReasoningStyle::GlmThinking
|
||||
}
|
||||
|
||||
fn normalize_reasoning_effort(
|
||||
&self,
|
||||
_request: &ChatRequest,
|
||||
_level: &str,
|
||||
_effort: String,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn use_max_completion_tokens(&self, _request: &ChatRequest) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn extra_body_fields(&self, reasoning: Option<&ResolvedReasoning>) -> Map<String, Value> {
|
||||
let mut extra = Map::new();
|
||||
let Some(reasoning) = reasoning else {
|
||||
return extra;
|
||||
};
|
||||
|
||||
let thinking_type = if matches!(reasoning.level.as_str(), "off" | "none") {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
};
|
||||
extra.insert(
|
||||
"thinking".to_string(),
|
||||
serde_json::json!({
|
||||
"type": thinking_type,
|
||||
"clear_thinking": true,
|
||||
}),
|
||||
);
|
||||
extra
|
||||
}
|
||||
}
|
||||
|
||||
impl GLMAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: OpenAICompatAdapter::new(GLMPolicy),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for GLMAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
self.inner.chat(ctx, request).await
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
self.inner.chat_stream(ctx, request)
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
self.inner.list_models(ctx).await
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
self.inner.embed(ctx, request).await
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
self.inner.validate_key(ctx).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::{Stream, stream};
|
||||
use serde::Deserialize;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::{ProviderAdapter, ProviderRequestContext, build_http_client};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.jina.ai/v1";
|
||||
|
||||
pub struct JinaAdapter {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl JinaAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: crate::build_default_http_client()
|
||||
.expect("Failed to build default HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(ctx: &ProviderRequestContext) -> String {
|
||||
ctx.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn get_client(&self, ctx: &ProviderRequestContext) -> Result<reqwest::Client> {
|
||||
match &ctx.proxy_config {
|
||||
Some(c) if c.proxy_type.as_deref() != Some("none") => build_http_client(Some(c)),
|
||||
_ => Ok(self.client.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported<T>() -> Result<T> {
|
||||
Err(BoosApiError::Provider(
|
||||
"Jina provider only supports rerank in BoosApi".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn jina_models(provider_id: &str) -> Vec<Model> {
|
||||
[
|
||||
"jina-reranker-v3",
|
||||
"jina-reranker-v2-base-multilingual",
|
||||
"jina-colbert-v2",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|model_id| Model {
|
||||
provider_id: provider_id.to_string(),
|
||||
model_id: model_id.to_string(),
|
||||
name: model_id.to_string(),
|
||||
group_name: None,
|
||||
model_type: ModelType::Rerank,
|
||||
capabilities: vec![],
|
||||
max_tokens: None,
|
||||
enabled: true,
|
||||
param_overrides: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn build_jina_rerank_body(request: &RerankRequest) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"model": request.model,
|
||||
"query": request.query,
|
||||
"documents": request.documents,
|
||||
"top_n": request.top_n,
|
||||
"return_documents": false,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NativeRerankResponse {
|
||||
results: Vec<NativeRerankResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NativeRerankResult {
|
||||
index: usize,
|
||||
#[serde(alias = "score")]
|
||||
relevance_score: f32,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_jina_rerank_response(body: &str) -> Result<RerankResponse> {
|
||||
let parsed: NativeRerankResponse = serde_json::from_str(body)
|
||||
.map_err(|e| BoosApiError::Provider(format!("Jina rerank parse error: {e}")))?;
|
||||
Ok(RerankResponse {
|
||||
results: parsed
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|r| RerankResult {
|
||||
index: r.index,
|
||||
relevance_score: r.relevance_score,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for JinaAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
unsupported()
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
Box::pin(stream::once(async {
|
||||
Err(BoosApiError::Provider(
|
||||
"Jina provider only supports rerank in BoosApi".into(),
|
||||
))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
Ok(jina_models(&ctx.provider_id))
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
unsupported()
|
||||
}
|
||||
|
||||
async fn rerank(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: RerankRequest,
|
||||
) -> Result<RerankResponse> {
|
||||
let url = format!("{}/rerank", Self::base_url(ctx));
|
||||
let resp = crate::apply_request_headers(
|
||||
self.get_client(ctx)?
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", ctx.api_key))
|
||||
.json(&build_jina_rerank_body(&request)),
|
||||
ctx,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Jina rerank request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Jina rerank API error {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Jina rerank body error: {e}")))?;
|
||||
parse_jina_rerank_response(&text)
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
let request = RerankRequest {
|
||||
model: "jina-reranker-v3".to_string(),
|
||||
query: "test".to_string(),
|
||||
documents: vec!["test".to_string()],
|
||||
top_n: 1,
|
||||
};
|
||||
self.rerank(ctx, request).await.map(|_| true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn jina_body_uses_native_top_n_and_disables_document_return() {
|
||||
let body = build_jina_rerank_body(&RerankRequest {
|
||||
model: "jina-reranker-v3".into(),
|
||||
query: "capital".into(),
|
||||
documents: vec!["doc".into()],
|
||||
top_n: 2,
|
||||
});
|
||||
|
||||
assert_eq!(body["model"], "jina-reranker-v3");
|
||||
assert_eq!(body["top_n"], 2);
|
||||
assert_eq!(body["return_documents"], false);
|
||||
assert!(body.get("top_k").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jina_parser_accepts_score_alias() {
|
||||
let parsed =
|
||||
parse_jina_rerank_response(r#"{"results":[{"index":1,"score":0.87}]}"#).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
RerankResponse {
|
||||
results: vec![RerankResult {
|
||||
index: 1,
|
||||
relevance_score: 0.87,
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
pub mod adapter;
|
||||
pub mod anthropic;
|
||||
pub mod cohere;
|
||||
pub mod custom_openai;
|
||||
pub mod deepseek;
|
||||
pub mod gemini;
|
||||
pub mod glm;
|
||||
pub mod jina;
|
||||
pub mod openai;
|
||||
pub mod openai_compat;
|
||||
pub mod openai_images;
|
||||
pub mod openai_responses;
|
||||
pub mod reasoning;
|
||||
pub mod registry;
|
||||
pub mod siliconflow;
|
||||
pub mod voyage;
|
||||
pub mod xai;
|
||||
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProviderAdapter: Send + Sync {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse>;
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>>;
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>>;
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: EmbedRequest,
|
||||
) -> Result<EmbedResponse>;
|
||||
|
||||
async fn rerank(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: RerankRequest,
|
||||
) -> Result<RerankResponse> {
|
||||
Err(BoosApiError::Provider(
|
||||
"Rerank is not supported by this provider".into(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Validate the API key. Default: try list_models.
|
||||
/// Providers may override for endpoints that don't support model listing.
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
self.list_models(ctx).await.map(|_| true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderRequestContext {
|
||||
pub api_key: String,
|
||||
pub key_id: String,
|
||||
pub provider_id: String,
|
||||
pub base_url: Option<String>,
|
||||
pub api_path: Option<String>,
|
||||
pub proxy_config: Option<ProviderProxyConfig>,
|
||||
pub custom_headers: Option<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Default version path for a given provider type.
|
||||
pub fn default_version_for_type(provider_type: &ProviderType) -> &'static str {
|
||||
match provider_type {
|
||||
ProviderType::Gemini => "/v1beta",
|
||||
ProviderType::Cohere => "/v2",
|
||||
ProviderType::GLM => "/v4",
|
||||
_ => "/v1",
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `api_host` into a usable base URL, using the provider type to
|
||||
/// determine the default version path (e.g. `/v1` for OpenAI, `/v1beta` for Gemini).
|
||||
///
|
||||
/// - Trailing `!` → force mode: strip `!`, return as-is.
|
||||
/// - Already ends with a versioned path (e.g. `/v1`, `/v1beta`) → return as-is.
|
||||
/// - Otherwise → append the default version path for this provider type.
|
||||
pub fn resolve_base_url_for_type(api_host: &str, provider_type: &ProviderType) -> String {
|
||||
let default_version = default_version_for_type(provider_type);
|
||||
resolve_base_url_inner(api_host, default_version)
|
||||
}
|
||||
|
||||
/// Resolve `api_host` into a usable base URL (defaults to `/v1`).
|
||||
pub fn resolve_base_url(api_host: &str) -> String {
|
||||
resolve_base_url_inner(api_host, "/v1")
|
||||
}
|
||||
|
||||
fn resolve_base_url_inner(api_host: &str, default_version: &str) -> String {
|
||||
let trimmed = api_host.trim_end_matches('/');
|
||||
if let Some(forced) = trimmed.strip_suffix('!') {
|
||||
forced.trim_end_matches('/').to_string()
|
||||
} else if has_version_suffix(trimmed) {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{}{}", trimmed, default_version)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the URL already ends with a versioned path segment
|
||||
/// like `/v1`, `/v1beta`, `/v2`, `/v1beta1`, etc.
|
||||
fn has_version_suffix(url: &str) -> bool {
|
||||
let last_seg = url.rsplit('/').next().unwrap_or("");
|
||||
// Match patterns like v1, v2, v1beta, v1beta1, v1alpha, etc.
|
||||
let bytes = last_seg.as_bytes();
|
||||
if bytes.len() < 2 || bytes[0] != b'v' {
|
||||
return false;
|
||||
}
|
||||
// After 'v', must start with digit(s), optionally followed by alpha tag
|
||||
let rest = &last_seg[1..];
|
||||
rest.starts_with(|c: char| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// Build the full chat/completion URL from resolved `base_url` and optional `api_path`.
|
||||
///
|
||||
/// When `api_path` is provided:
|
||||
/// - Trailing `!` on api_path → force: concat resolved base + raw path (strip `!`).
|
||||
/// - No `!` → auto-dedup: if both resolved base and api_path share a common
|
||||
/// versioned prefix (e.g. `/v1`, `/v1beta`), strip the duplicate from api_path.
|
||||
///
|
||||
/// When `api_path` is absent, returns `resolved_base_url + default_suffix`
|
||||
/// (e.g. `/chat/completions`).
|
||||
pub fn resolve_chat_url(
|
||||
resolved_base: &str,
|
||||
api_path: Option<&str>,
|
||||
default_suffix: &str,
|
||||
) -> String {
|
||||
let base = resolved_base.trim_end_matches('/');
|
||||
match api_path {
|
||||
Some(path) if !path.is_empty() => {
|
||||
if let Some(forced) = path.strip_suffix('!') {
|
||||
// Force mode: concat as-is
|
||||
let p = if forced.starts_with('/') {
|
||||
forced.to_string()
|
||||
} else {
|
||||
format!("/{}", forced)
|
||||
};
|
||||
format!("{}{}", base, p)
|
||||
} else {
|
||||
let p = if path.starts_with('/') {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("/{}", path)
|
||||
};
|
||||
// Auto dedup: if base ends with a version prefix that matches
|
||||
// the start of api_path, strip it from api_path
|
||||
if let Some(ver) = extract_version_prefix(base) {
|
||||
if p.starts_with(&ver) {
|
||||
return format!("{}{}", base, &p[ver.len()..]);
|
||||
}
|
||||
}
|
||||
format!("{}{}", base, p)
|
||||
}
|
||||
}
|
||||
_ => format!("{}{}", base, default_suffix),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the model-list URL from a resolved base URL.
|
||||
pub fn resolve_models_url(resolved_base: &str) -> String {
|
||||
format!("{}/models", resolved_base.trim_end_matches('/'))
|
||||
}
|
||||
|
||||
/// Extract the trailing version prefix from a URL (e.g. "/v1", "/v1beta").
|
||||
fn extract_version_prefix(url: &str) -> Option<String> {
|
||||
let last_seg = url.rsplit('/').next()?;
|
||||
let bytes = last_seg.as_bytes();
|
||||
if bytes.len() < 2 || bytes[0] != b'v' {
|
||||
return None;
|
||||
}
|
||||
let rest = &last_seg[1..];
|
||||
if rest.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
Some(format!("/{}", last_seg))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_models_url_uses_resolved_base_url() {
|
||||
let base = resolve_base_url_for_type("https://api.openai.com", &ProviderType::OpenAI);
|
||||
assert_eq!(resolve_models_url(&base), "https://api.openai.com/v1/models");
|
||||
|
||||
let base = resolve_base_url_for_type("https://api.openai.com/v1", &ProviderType::OpenAI);
|
||||
assert_eq!(resolve_models_url(&base), "https://api.openai.com/v1/models");
|
||||
|
||||
let base = resolve_base_url_for_type("https://api.example.com!", &ProviderType::OpenAI);
|
||||
assert_eq!(resolve_models_url(&base), "https://api.example.com/models");
|
||||
|
||||
let base = resolve_base_url_for_type("https://open.bigmodel.cn/api/paas", &ProviderType::GLM);
|
||||
assert_eq!(
|
||||
resolve_models_url(&base),
|
||||
"https://open.bigmodel.cn/api/paas/v4/models"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_base64_data_url(url: &str) -> Option<(String, String)> {
|
||||
let rest = url.strip_prefix("data:")?;
|
||||
let (mime_type, data) = rest.split_once(";base64,")?;
|
||||
if mime_type.is_empty() || data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((mime_type.to_string(), data.to_string()))
|
||||
}
|
||||
|
||||
/// Build an HTTP client with optional proxy configuration.
|
||||
/// - "system": use system proxy auto-detection (reqwest default)
|
||||
/// - "http"/"socks5": use explicit proxy with address/port
|
||||
/// - None or "none": disable all proxies
|
||||
pub fn build_http_client(proxy_config: Option<&ProviderProxyConfig>) -> Result<reqwest::Client> {
|
||||
let mut builder = reqwest::Client::builder().use_rustls_tls();
|
||||
|
||||
if let Some(config) = proxy_config {
|
||||
match config.proxy_type.as_deref() {
|
||||
Some("system") => {
|
||||
// Don't call .no_proxy() — let reqwest auto-detect system proxy
|
||||
}
|
||||
Some(proxy_type) if proxy_type != "none" => {
|
||||
if let (Some(addr), Some(port)) = (&config.proxy_address, &config.proxy_port) {
|
||||
if !addr.is_empty() {
|
||||
let scheme = if proxy_type == "socks5" {
|
||||
"socks5"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let proxy_url = format!("{}://{}:{}", scheme, addr, port);
|
||||
let proxy = reqwest::Proxy::all(&proxy_url).map_err(|e| {
|
||||
BoosApiError::Provider(format!("Invalid proxy URL: {}", e))
|
||||
})?;
|
||||
builder = builder.proxy(proxy);
|
||||
} else {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
} else {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
builder = builder.no_proxy();
|
||||
}
|
||||
|
||||
builder
|
||||
.tcp_nodelay(true)
|
||||
.build()
|
||||
.map_err(|e| BoosApiError::Provider(format!("Failed to build HTTP client: {}", e)))
|
||||
}
|
||||
|
||||
pub fn build_default_http_client() -> Result<reqwest::Client> {
|
||||
build_http_client(None)
|
||||
}
|
||||
|
||||
/// Default User-Agent: `BoosApi-{os}_{arch}/{version}`
|
||||
pub fn default_user_agent() -> String {
|
||||
format!(
|
||||
"BoosApi-{}_{}/{}",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)
|
||||
}
|
||||
|
||||
/// Apply custom headers + default User-Agent to a request builder.
|
||||
pub fn apply_request_headers(
|
||||
builder: reqwest::RequestBuilder,
|
||||
ctx: &ProviderRequestContext,
|
||||
) -> reqwest::RequestBuilder {
|
||||
apply_headers_to_request(builder, &ctx.custom_headers)
|
||||
}
|
||||
|
||||
/// Apply custom headers + default User-Agent from a raw headers map.
|
||||
pub fn apply_headers_to_request(
|
||||
mut builder: reqwest::RequestBuilder,
|
||||
custom_headers: &Option<std::collections::HashMap<String, String>>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
let mut has_ua = false;
|
||||
if let Some(ref headers) = custom_headers {
|
||||
for (key, value) in headers {
|
||||
if key.eq_ignore_ascii_case("user-agent") {
|
||||
has_ua = true;
|
||||
}
|
||||
builder = builder.header(key, value);
|
||||
}
|
||||
}
|
||||
if !has_ua {
|
||||
builder = builder.header("User-Agent", default_user_agent());
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
/// Force uncompressed transfer for streaming requests so SSE chunks are not
|
||||
/// delayed by upstream/content-encoding buffering.
|
||||
pub fn apply_stream_headers_to_request(
|
||||
builder: reqwest::RequestBuilder,
|
||||
custom_headers: &Option<std::collections::HashMap<String, String>>,
|
||||
) -> reqwest::RequestBuilder {
|
||||
apply_headers_to_request(builder, custom_headers).header("Accept-Encoding", "identity")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use boosapi_core::error::Result;
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::openai_compat::{OpenAICompatAdapter, OpenAICompatPolicy};
|
||||
use crate::reasoning::{ReasoningStyle, ResolvedReasoning};
|
||||
use crate::{ProviderAdapter, ProviderRequestContext};
|
||||
|
||||
pub struct OpenAIAdapter {
|
||||
inner: OpenAICompatAdapter<OpenAIPolicy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct OpenAIPolicy;
|
||||
|
||||
impl OpenAICompatPolicy for OpenAIPolicy {
|
||||
fn max_completion_tokens_cap(&self, request: &ChatRequest) -> Option<u32> {
|
||||
crate::deepseek::deepseek_compat_max_completion_tokens_cap(request)
|
||||
}
|
||||
|
||||
fn suppress_sampling_params(&self, reasoning: Option<&ResolvedReasoning>) -> bool {
|
||||
reasoning.is_some_and(|r| {
|
||||
matches!(
|
||||
r.style,
|
||||
ReasoningStyle::OpenAIReasoningEffort | ReasoningStyle::OpenAIResponsesReasoning
|
||||
) && !matches!(r.level.as_str(), "off" | "none")
|
||||
&& r.suppress_sampling_params
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAIAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: OpenAICompatAdapter::new(OpenAIPolicy),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for OpenAIAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
self.inner.chat(ctx, request).await
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
self.inner.chat_stream(ctx, request)
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
self.inner.list_models(ctx).await
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
self.inner.embed(ctx, request).await
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
self.inner.validate_key(ctx).await
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,805 @@
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use base64::Engine;
|
||||
use futures::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
apply_request_headers, build_default_http_client, build_http_client, ProviderRequestContext,
|
||||
};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
|
||||
const MAX_IMAGE_DOWNLOAD_BYTES: usize = 50 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ImageGenerateRequest {
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub n: u8,
|
||||
pub size: String,
|
||||
pub quality: String,
|
||||
pub output_format: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output_compression: Option<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageEditRequest {
|
||||
pub model: String,
|
||||
pub prompt: String,
|
||||
pub n: u8,
|
||||
pub size: String,
|
||||
pub quality: String,
|
||||
pub output_format: String,
|
||||
pub background: Option<String>,
|
||||
pub output_compression: Option<u8>,
|
||||
pub transfer_mode: ImageEditTransferMode,
|
||||
pub image_format: ImageEditImageFormat,
|
||||
pub image_param_name: String,
|
||||
pub images: Vec<ImageUpload>,
|
||||
pub mask: Option<ImageUpload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImageEditTransferMode {
|
||||
Multipart,
|
||||
Base64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImageEditImageFormat {
|
||||
Object,
|
||||
String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageUpload {
|
||||
pub bytes: Vec<u8>,
|
||||
pub file_name: String,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageApiOutput {
|
||||
pub response_id: Option<String>,
|
||||
pub usage_json: Option<String>,
|
||||
pub images: Vec<ImageApiImage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageApiImage {
|
||||
pub bytes: Vec<u8>,
|
||||
pub revised_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImageApiResponse {
|
||||
id: Option<String>,
|
||||
usage: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
data: Vec<ImageData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImageData {
|
||||
b64_json: Option<String>,
|
||||
url: Option<String>,
|
||||
revised_prompt: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OpenAIImagesClient {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl OpenAIImagesClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: build_default_http_client().expect("Failed to build default HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(ctx: &ProviderRequestContext) -> String {
|
||||
ctx.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn image_url(ctx: &ProviderRequestContext, suffix: &str) -> String {
|
||||
format!("{}{}", Self::base_url(ctx).trim_end_matches('/'), suffix)
|
||||
}
|
||||
|
||||
fn generate_url(ctx: &ProviderRequestContext, path: Option<&str>) -> String {
|
||||
let default_path = "/images/generations";
|
||||
Self::image_url(ctx, path.unwrap_or(default_path))
|
||||
}
|
||||
|
||||
fn edit_url(ctx: &ProviderRequestContext, path: Option<&str>) -> String {
|
||||
let default_path = "/images/edits";
|
||||
Self::image_url(ctx, path.unwrap_or(default_path))
|
||||
}
|
||||
|
||||
fn get_client(&self, ctx: &ProviderRequestContext) -> Result<reqwest::Client> {
|
||||
match &ctx.proxy_config {
|
||||
Some(c) if c.proxy_type.as_deref() != Some("none") => build_http_client(Some(c)),
|
||||
_ => Ok(self.client.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn generate(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ImageGenerateRequest,
|
||||
path: Option<&str>,
|
||||
) -> Result<ImageApiOutput> {
|
||||
let client = self.get_client(ctx)?;
|
||||
let builder = client
|
||||
.post(Self::generate_url(ctx, path))
|
||||
.bearer_auth(&ctx.api_key)
|
||||
.json(&request);
|
||||
let response = apply_request_headers(builder, ctx)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Image generation failed: {}", e)))?;
|
||||
parse_response(&client, response).await
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ImageEditRequest,
|
||||
path: Option<&str>,
|
||||
) -> Result<ImageApiOutput> {
|
||||
let client = self.get_client(ctx)?;
|
||||
let builder = client
|
||||
.post(Self::edit_url(ctx, path))
|
||||
.bearer_auth(&ctx.api_key);
|
||||
let builder = match request.transfer_mode {
|
||||
ImageEditTransferMode::Multipart => {
|
||||
builder.multipart(build_edit_multipart_form(request)?)
|
||||
}
|
||||
ImageEditTransferMode::Base64 => {
|
||||
let body = build_edit_json_request(request)?;
|
||||
builder
|
||||
.body(body)
|
||||
.header("Content-Type", "application/json")
|
||||
}
|
||||
};
|
||||
let response = apply_request_headers(builder, ctx)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Image edit failed: {}", e)))?;
|
||||
parse_response(&client, response).await
|
||||
}
|
||||
}
|
||||
|
||||
fn image_upload_to_part(upload: ImageUpload) -> Result<reqwest::multipart::Part> {
|
||||
reqwest::multipart::Part::bytes(upload.bytes)
|
||||
.file_name(upload.file_name)
|
||||
.mime_str(&upload.mime_type)
|
||||
.map_err(|e| BoosApiError::Provider(format!("Invalid image MIME type: {}", e)))
|
||||
}
|
||||
|
||||
fn build_edit_multipart_form(request: ImageEditRequest) -> Result<reqwest::multipart::Form> {
|
||||
let image_param_name = multipart_image_param_name(&request.image_param_name).to_string();
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.text("model", request.model)
|
||||
.text("prompt", request.prompt)
|
||||
.text("n", request.n.to_string())
|
||||
.text("size", request.size)
|
||||
.text("quality", request.quality)
|
||||
.text("output_format", request.output_format);
|
||||
|
||||
if let Some(background) = request.background {
|
||||
form = form.text("background", background);
|
||||
}
|
||||
if let Some(output_compression) = request.output_compression {
|
||||
form = form.text("output_compression", output_compression.to_string());
|
||||
}
|
||||
|
||||
for upload in request.images {
|
||||
form = form.part(image_param_name.clone(), image_upload_to_part(upload)?);
|
||||
}
|
||||
if let Some(mask) = request.mask {
|
||||
form = form.part("mask", image_upload_to_part(mask)?);
|
||||
}
|
||||
|
||||
Ok(form)
|
||||
}
|
||||
|
||||
fn image_upload_to_string(upload: ImageUpload) -> String {
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(upload.bytes);
|
||||
format!("data:{};base64,{}", upload.mime_type, data)
|
||||
}
|
||||
|
||||
fn image_upload_to_image_url_object(upload: ImageUpload) -> serde_json::Value {
|
||||
let mut obj = serde_json::Map::new();
|
||||
obj.insert(
|
||||
"image_url".to_string(),
|
||||
serde_json::Value::String(image_upload_to_string(upload)),
|
||||
);
|
||||
serde_json::Value::Object(obj)
|
||||
}
|
||||
|
||||
fn multipart_image_param_name(value: &str) -> &str {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
"image[]"
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
fn json_image_param_name(value: &str) -> &str {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
"images"
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
fn build_edit_json_request(request: ImageEditRequest) -> Result<Vec<u8>> {
|
||||
let mut map = serde_json::Map::new();
|
||||
let image_param_name = json_image_param_name(&request.image_param_name).to_string();
|
||||
map.insert(
|
||||
"model".to_string(),
|
||||
serde_json::Value::String(request.model),
|
||||
);
|
||||
map.insert(
|
||||
"prompt".to_string(),
|
||||
serde_json::Value::String(request.prompt),
|
||||
);
|
||||
map.insert("n".to_string(), serde_json::Value::Number(request.n.into()));
|
||||
map.insert("size".to_string(), serde_json::Value::String(request.size));
|
||||
map.insert(
|
||||
"quality".to_string(),
|
||||
serde_json::Value::String(request.quality),
|
||||
);
|
||||
map.insert(
|
||||
"output_format".to_string(),
|
||||
serde_json::Value::String(request.output_format),
|
||||
);
|
||||
|
||||
if let Some(background) = request.background {
|
||||
map.insert(
|
||||
"background".to_string(),
|
||||
serde_json::Value::String(background),
|
||||
);
|
||||
}
|
||||
if let Some(output_compression) = request.output_compression {
|
||||
map.insert(
|
||||
"output_compression".to_string(),
|
||||
serde_json::Value::Number(output_compression.into()),
|
||||
);
|
||||
}
|
||||
|
||||
if request.images.is_empty() {
|
||||
return Err(BoosApiError::Provider(
|
||||
"No image provided for edit request".into(),
|
||||
));
|
||||
}
|
||||
|
||||
match request.image_format {
|
||||
ImageEditImageFormat::Object => {
|
||||
let images: Vec<serde_json::Value> = request
|
||||
.images
|
||||
.into_iter()
|
||||
.map(image_upload_to_image_url_object)
|
||||
.collect();
|
||||
map.insert(image_param_name, serde_json::Value::Array(images));
|
||||
}
|
||||
ImageEditImageFormat::String => {
|
||||
let images: Vec<serde_json::Value> = request
|
||||
.images
|
||||
.into_iter()
|
||||
.map(|upload| serde_json::Value::String(image_upload_to_string(upload)))
|
||||
.collect();
|
||||
map.insert(image_param_name, serde_json::Value::Array(images));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(mask) = request.mask {
|
||||
let mask_value = match request.image_format {
|
||||
ImageEditImageFormat::Object => image_upload_to_image_url_object(mask),
|
||||
ImageEditImageFormat::String => {
|
||||
serde_json::Value::String(image_upload_to_string(mask))
|
||||
}
|
||||
};
|
||||
map.insert("mask".to_string(), mask_value);
|
||||
}
|
||||
|
||||
let value = serde_json::Value::Object(map);
|
||||
serde_json::to_vec(&value)
|
||||
.map_err(|e| BoosApiError::Provider(format!("Failed to serialize edit request: {}", e)))
|
||||
}
|
||||
|
||||
async fn fetch_image_url(client: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Failed to fetch image from URL: {}", e)))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Image URL returned HTTP {}",
|
||||
status
|
||||
)));
|
||||
}
|
||||
if let Some(content_length) = response.content_length() {
|
||||
if content_length > MAX_IMAGE_DOWNLOAD_BYTES as u64 {
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Image URL response exceeds {} bytes",
|
||||
MAX_IMAGE_DOWNLOAD_BYTES
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let mut bytes = Vec::new();
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk
|
||||
.map_err(|e| BoosApiError::Provider(format!("Failed to read image bytes: {}", e)))?;
|
||||
let next_len = bytes.len().saturating_add(chunk.len());
|
||||
if next_len > MAX_IMAGE_DOWNLOAD_BYTES {
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Image URL response exceeds {} bytes",
|
||||
MAX_IMAGE_DOWNLOAD_BYTES
|
||||
)));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
if bytes.is_empty() {
|
||||
return Err(BoosApiError::Provider("Image URL response is empty".into()));
|
||||
}
|
||||
image::load_from_memory(&bytes).map_err(|e| {
|
||||
BoosApiError::Provider(format!("Image URL response is not a valid image: {}", e))
|
||||
})?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
async fn parse_response(
|
||||
client: &reqwest::Client,
|
||||
response: reqwest::Response,
|
||||
) -> Result<ImageApiOutput> {
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"OpenAI image API error {}: {}",
|
||||
status, text
|
||||
)));
|
||||
}
|
||||
let body: ImageApiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Invalid image API response: {}", e)))?;
|
||||
|
||||
let mut images = Vec::with_capacity(body.data.len());
|
||||
for item in body.data {
|
||||
let bytes = if let Some(encoded) = item.b64_json {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|e| BoosApiError::Provider(format!("Invalid image b64_json: {}", e)))?
|
||||
} else if let Some(url) = item.url {
|
||||
fetch_image_url(client, &url).await?
|
||||
} else {
|
||||
return Err(BoosApiError::Provider(
|
||||
"Image API response missing both b64_json and url".into(),
|
||||
));
|
||||
};
|
||||
images.push(ImageApiImage {
|
||||
bytes,
|
||||
revised_prompt: item.revised_prompt,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ImageApiOutput {
|
||||
response_id: body.id,
|
||||
usage_json: body.usage.map(|u| u.to_string()),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
async fn spawn_http_response(
|
||||
response: String,
|
||||
) -> (std::net::SocketAddr, tokio::task::JoinHandle<String>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind test server");
|
||||
let addr = listener.local_addr().expect("server addr");
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.expect("accept request");
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0u8; 4096];
|
||||
let mut header_end = None;
|
||||
let mut content_length = 0usize;
|
||||
|
||||
loop {
|
||||
let read = socket.read(&mut buffer).await.expect("read request");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
|
||||
if header_end.is_none() {
|
||||
header_end = request.windows(4).position(|window| window == b"\r\n\r\n");
|
||||
if let Some(end) = header_end {
|
||||
let headers = String::from_utf8_lossy(&request[..end]);
|
||||
content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_ascii_lowercase()
|
||||
.strip_prefix("content-length:")
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(end) = header_end {
|
||||
if request.len() >= end + 4 + content_length {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("write response");
|
||||
|
||||
String::from_utf8_lossy(&request).into_owned()
|
||||
});
|
||||
(addr, server)
|
||||
}
|
||||
|
||||
fn http_response(status: &str, content_type: &str, body: &str) -> String {
|
||||
format!(
|
||||
"HTTP/1.1 {}\r\ncontent-type: {}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
status,
|
||||
content_type,
|
||||
body.len(),
|
||||
body
|
||||
)
|
||||
}
|
||||
|
||||
fn context_with_chat_path() -> ProviderRequestContext {
|
||||
ProviderRequestContext {
|
||||
api_key: "sk-test".to_string(),
|
||||
key_id: "key".to_string(),
|
||||
provider_id: "provider".to_string(),
|
||||
base_url: Some("https://api.openai.com/v1".to_string()),
|
||||
api_path: Some("/v1/chat/completions".to_string()),
|
||||
proxy_config: None,
|
||||
custom_headers: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_urls_ignore_chat_api_path() {
|
||||
let ctx = context_with_chat_path();
|
||||
|
||||
assert_eq!(
|
||||
OpenAIImagesClient::generate_url(&ctx, None),
|
||||
"https://api.openai.com/v1/images/generations"
|
||||
);
|
||||
assert_eq!(
|
||||
OpenAIImagesClient::edit_url(&ctx, None),
|
||||
"https://api.openai.com/v1/images/edits"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_request_body_serializes_images_as_official_image_url_objects() {
|
||||
let body = build_edit_json_request(ImageEditRequest {
|
||||
model: "gpt-image-2".to_string(),
|
||||
prompt: "换成马斯克".to_string(),
|
||||
n: 1,
|
||||
size: "1024x1024".to_string(),
|
||||
quality: "high".to_string(),
|
||||
output_format: "png".to_string(),
|
||||
background: Some("auto".to_string()),
|
||||
output_compression: None,
|
||||
transfer_mode: ImageEditTransferMode::Base64,
|
||||
image_format: ImageEditImageFormat::Object,
|
||||
image_param_name: "images".to_string(),
|
||||
images: vec![ImageUpload {
|
||||
bytes: b"abc".to_vec(),
|
||||
file_name: "reference.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
}],
|
||||
mask: None,
|
||||
})
|
||||
.expect("build edit json request");
|
||||
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("parse json body");
|
||||
assert_eq!(value["model"], "gpt-image-2");
|
||||
assert_eq!(value["prompt"], "换成马斯克");
|
||||
assert_eq!(
|
||||
value["images"][0]["image_url"],
|
||||
"data:image/jpeg;base64,YWJj"
|
||||
);
|
||||
assert!(value["images"][0].get("url").is_none());
|
||||
assert!(value.get("mask").is_none());
|
||||
assert!(value.get("tools").is_none());
|
||||
|
||||
let serialized = value.to_string();
|
||||
assert!(!serialized.contains("image[]"));
|
||||
assert!(!serialized.contains("reference.jpg"));
|
||||
assert!(!serialized.contains("bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_request_body_serializes_images_as_string_array() {
|
||||
let body = build_edit_json_request(ImageEditRequest {
|
||||
model: "gpt-image-2".to_string(),
|
||||
prompt: "换成马斯克".to_string(),
|
||||
n: 1,
|
||||
size: "1024x1024".to_string(),
|
||||
quality: "high".to_string(),
|
||||
output_format: "png".to_string(),
|
||||
background: Some("auto".to_string()),
|
||||
output_compression: None,
|
||||
transfer_mode: ImageEditTransferMode::Base64,
|
||||
image_format: ImageEditImageFormat::String,
|
||||
image_param_name: "images".to_string(),
|
||||
images: vec![ImageUpload {
|
||||
bytes: b"abc".to_vec(),
|
||||
file_name: "reference.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
}],
|
||||
mask: None,
|
||||
})
|
||||
.expect("build edit json request");
|
||||
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("parse json body");
|
||||
assert_eq!(value["model"], "gpt-image-2");
|
||||
assert_eq!(value["prompt"], "换成马斯克");
|
||||
assert_eq!(value["images"][0], "data:image/jpeg;base64,YWJj");
|
||||
assert!(value.get("mask").is_none());
|
||||
|
||||
let serialized = value.to_string();
|
||||
assert!(!serialized.contains("image[]"));
|
||||
assert!(!serialized.contains("reference.jpg"));
|
||||
assert!(!serialized.contains("bytes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_request_body_serializes_multiple_images_and_mask_as_image_url_objects() {
|
||||
let body = build_edit_json_request(ImageEditRequest {
|
||||
model: "gpt-image-2".to_string(),
|
||||
prompt: "只替换遮罩区域".to_string(),
|
||||
n: 4,
|
||||
size: "auto".to_string(),
|
||||
quality: "auto".to_string(),
|
||||
output_format: "webp".to_string(),
|
||||
background: None,
|
||||
output_compression: Some(80),
|
||||
transfer_mode: ImageEditTransferMode::Base64,
|
||||
image_format: ImageEditImageFormat::Object,
|
||||
image_param_name: "images".to_string(),
|
||||
images: vec![
|
||||
ImageUpload {
|
||||
bytes: b"source".to_vec(),
|
||||
file_name: "source.png".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
},
|
||||
ImageUpload {
|
||||
bytes: b"ref".to_vec(),
|
||||
file_name: "ref.webp".to_string(),
|
||||
mime_type: "image/webp".to_string(),
|
||||
},
|
||||
],
|
||||
mask: Some(ImageUpload {
|
||||
bytes: b"mask".to_vec(),
|
||||
file_name: "mask.png".to_string(),
|
||||
mime_type: "image/png".to_string(),
|
||||
}),
|
||||
})
|
||||
.expect("build edit json request");
|
||||
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("parse json body");
|
||||
assert_eq!(value["n"], 4);
|
||||
assert_eq!(value["images"].as_array().expect("images array").len(), 2);
|
||||
assert_eq!(
|
||||
value["images"][0]["image_url"],
|
||||
"data:image/png;base64,c291cmNl"
|
||||
);
|
||||
assert_eq!(
|
||||
value["images"][1]["image_url"],
|
||||
"data:image/webp;base64,cmVm"
|
||||
);
|
||||
assert_eq!(value["mask"]["image_url"], "data:image/png;base64,bWFzaw==");
|
||||
assert_eq!(value["output_compression"], 80);
|
||||
assert!(value.get("background").is_none());
|
||||
assert!(value.get("tools").is_none());
|
||||
|
||||
let serialized = value.to_string();
|
||||
assert!(!serialized.contains("image[]"));
|
||||
assert!(!serialized.contains("file_name"));
|
||||
assert!(!serialized.contains("mime_type"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_request_with_custom_image_param_name() {
|
||||
let body = build_edit_json_request(ImageEditRequest {
|
||||
model: "gpt-image-2".to_string(),
|
||||
prompt: "生成图片".to_string(),
|
||||
n: 1,
|
||||
size: "1024x1024".to_string(),
|
||||
quality: "high".to_string(),
|
||||
output_format: "png".to_string(),
|
||||
background: None,
|
||||
output_compression: None,
|
||||
transfer_mode: ImageEditTransferMode::Base64,
|
||||
image_format: ImageEditImageFormat::String,
|
||||
image_param_name: "image_urls".to_string(),
|
||||
images: vec![ImageUpload {
|
||||
bytes: b"test".to_vec(),
|
||||
file_name: "test.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
}],
|
||||
mask: None,
|
||||
})
|
||||
.expect("build edit json request");
|
||||
|
||||
let value: serde_json::Value = serde_json::from_slice(&body).expect("parse json body");
|
||||
assert!(value.get("image_urls").is_some());
|
||||
assert!(value.get("images").is_none());
|
||||
assert_eq!(value["image_urls"][0], "data:image/jpeg;base64,dGVzdA==");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_sends_multipart_body_with_official_image_array_field_when_requested() {
|
||||
let body = r#"{"data":[{"b64_json":"aW1n"}]}"#;
|
||||
let (addr, server) =
|
||||
spawn_http_response(http_response("200 OK", "application/json", body)).await;
|
||||
|
||||
let ctx = ProviderRequestContext {
|
||||
api_key: "sk-test".to_string(),
|
||||
key_id: "key".to_string(),
|
||||
provider_id: "provider".to_string(),
|
||||
base_url: Some(format!("http://{}", addr)),
|
||||
api_path: None,
|
||||
proxy_config: None,
|
||||
custom_headers: None,
|
||||
};
|
||||
|
||||
let output = OpenAIImagesClient::new()
|
||||
.edit(
|
||||
&ctx,
|
||||
ImageEditRequest {
|
||||
model: "gpt-image-2".to_string(),
|
||||
prompt: "参考图生成".to_string(),
|
||||
n: 1,
|
||||
size: "auto".to_string(),
|
||||
quality: "auto".to_string(),
|
||||
output_format: "png".to_string(),
|
||||
background: None,
|
||||
output_compression: None,
|
||||
transfer_mode: ImageEditTransferMode::Multipart,
|
||||
image_format: ImageEditImageFormat::String,
|
||||
image_param_name: "".to_string(),
|
||||
images: vec![ImageUpload {
|
||||
bytes: b"abc".to_vec(),
|
||||
file_name: "reference.jpg".to_string(),
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
}],
|
||||
mask: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("edit response");
|
||||
|
||||
let request = server.await.expect("server captured request");
|
||||
assert_eq!(output.images[0].bytes, b"img");
|
||||
assert!(request.starts_with("POST /images/edits HTTP/1.1"));
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("content-type: multipart/form-data; boundary="));
|
||||
assert!(request.contains("name=\"image[]\""));
|
||||
assert!(!request.contains("name=\"image\""));
|
||||
assert!(!request.contains("name=\"reference_images\""));
|
||||
assert!(request.contains("filename=\"reference.jpg\""));
|
||||
assert!(!request.contains("data:image/jpeg;base64"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_url_response_rejects_non_success_download() {
|
||||
let (image_addr, image_server) =
|
||||
spawn_http_response(http_response("404 Not Found", "text/plain", "missing")).await;
|
||||
let body = format!(
|
||||
r#"{{"data":[{{"url":"http://{}/missing.png"}}]}}"#,
|
||||
image_addr
|
||||
);
|
||||
let (api_addr, api_server) =
|
||||
spawn_http_response(http_response("200 OK", "application/json", &body)).await;
|
||||
|
||||
let ctx = ProviderRequestContext {
|
||||
api_key: "sk-test".to_string(),
|
||||
key_id: "key".to_string(),
|
||||
provider_id: "provider".to_string(),
|
||||
base_url: Some(format!("http://{}", api_addr)),
|
||||
api_path: None,
|
||||
proxy_config: None,
|
||||
custom_headers: None,
|
||||
};
|
||||
|
||||
let err = OpenAIImagesClient::new()
|
||||
.generate(
|
||||
&ctx,
|
||||
ImageGenerateRequest {
|
||||
model: "gpt-image-2".to_string(),
|
||||
prompt: "生成图片".to_string(),
|
||||
n: 1,
|
||||
size: "auto".to_string(),
|
||||
quality: "auto".to_string(),
|
||||
output_format: "png".to_string(),
|
||||
background: None,
|
||||
output_compression: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("non-success image URL should be rejected");
|
||||
|
||||
assert!(err.to_string().contains("Image URL returned HTTP 404"));
|
||||
api_server.await.expect("api server request");
|
||||
image_server.await.expect("image server request");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_url_response_rejects_non_image_body() {
|
||||
let (image_addr, image_server) =
|
||||
spawn_http_response(http_response("200 OK", "text/plain", "not an image")).await;
|
||||
let body = format!(
|
||||
r#"{{"data":[{{"url":"http://{}/image.png"}}]}}"#,
|
||||
image_addr
|
||||
);
|
||||
let (api_addr, api_server) =
|
||||
spawn_http_response(http_response("200 OK", "application/json", &body)).await;
|
||||
|
||||
let ctx = ProviderRequestContext {
|
||||
api_key: "sk-test".to_string(),
|
||||
key_id: "key".to_string(),
|
||||
provider_id: "provider".to_string(),
|
||||
base_url: Some(format!("http://{}", api_addr)),
|
||||
api_path: None,
|
||||
proxy_config: None,
|
||||
custom_headers: None,
|
||||
};
|
||||
|
||||
let err = OpenAIImagesClient::new()
|
||||
.generate(
|
||||
&ctx,
|
||||
ImageGenerateRequest {
|
||||
model: "gpt-image-2".to_string(),
|
||||
prompt: "生成图片".to_string(),
|
||||
n: 1,
|
||||
size: "auto".to_string(),
|
||||
quality: "auto".to_string(),
|
||||
output_format: "png".to_string(),
|
||||
background: None,
|
||||
output_compression: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect_err("non-image body should be rejected");
|
||||
|
||||
assert!(err
|
||||
.to_string()
|
||||
.contains("Image URL response is not a valid image"));
|
||||
api_server.await.expect("api server request");
|
||||
image_server.await.expect("image server request");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
use boosapi_core::types::ChatRequest;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ReasoningStyle {
|
||||
None,
|
||||
OpenAIReasoningEffort,
|
||||
OpenAIResponsesReasoning,
|
||||
GlmThinking,
|
||||
GeminiThinkingLevel,
|
||||
GeminiThinkingBudget,
|
||||
AnthropicAdaptive,
|
||||
AnthropicBudgetTokens,
|
||||
SiliconFlowEnableThinking,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedReasoning {
|
||||
pub style: ReasoningStyle,
|
||||
pub level: String,
|
||||
pub reasoning_effort: Option<String>,
|
||||
pub thinking_level: Option<String>,
|
||||
pub budget_tokens: Option<u32>,
|
||||
pub enable_thinking: Option<bool>,
|
||||
pub suppress_sampling_params: bool,
|
||||
}
|
||||
|
||||
pub fn resolve_reasoning(
|
||||
request: &ChatRequest,
|
||||
default_style: ReasoningStyle,
|
||||
) -> Option<ResolvedReasoning> {
|
||||
let style = request
|
||||
.reasoning_profile
|
||||
.as_deref()
|
||||
.map(reasoning_style_from_profile)
|
||||
.unwrap_or(default_style);
|
||||
if style == ReasoningStyle::None {
|
||||
return None;
|
||||
}
|
||||
|
||||
let level = request
|
||||
.thinking_level
|
||||
.clone()
|
||||
.or_else(|| request.thinking_budget.map(legacy_budget_to_level))?;
|
||||
|
||||
if level == "default" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let suppress_sampling_params = !matches!(level.as_str(), "off" | "none");
|
||||
let budget_tokens = level_to_budget(&level);
|
||||
let reasoning_effort = level_to_effort(&level).map(str::to_string);
|
||||
|
||||
match style {
|
||||
ReasoningStyle::OpenAIReasoningEffort | ReasoningStyle::OpenAIResponsesReasoning => {
|
||||
Some(ResolvedReasoning {
|
||||
style,
|
||||
level,
|
||||
reasoning_effort,
|
||||
thinking_level: None,
|
||||
budget_tokens: None,
|
||||
enable_thinking: None,
|
||||
suppress_sampling_params,
|
||||
})
|
||||
}
|
||||
ReasoningStyle::GlmThinking => Some(ResolvedReasoning {
|
||||
style,
|
||||
level,
|
||||
reasoning_effort: None,
|
||||
thinking_level: None,
|
||||
budget_tokens: None,
|
||||
enable_thinking: None,
|
||||
suppress_sampling_params,
|
||||
}),
|
||||
ReasoningStyle::GeminiThinkingLevel => {
|
||||
if !matches!(level.as_str(), "minimal" | "low" | "medium" | "high") {
|
||||
return None;
|
||||
}
|
||||
Some(ResolvedReasoning {
|
||||
style,
|
||||
thinking_level: Some(level.clone()),
|
||||
level,
|
||||
reasoning_effort: None,
|
||||
budget_tokens: None,
|
||||
enable_thinking: None,
|
||||
suppress_sampling_params: false,
|
||||
})
|
||||
}
|
||||
ReasoningStyle::GeminiThinkingBudget | ReasoningStyle::AnthropicBudgetTokens => {
|
||||
Some(ResolvedReasoning {
|
||||
style,
|
||||
level,
|
||||
reasoning_effort: None,
|
||||
thinking_level: None,
|
||||
budget_tokens,
|
||||
enable_thinking: None,
|
||||
suppress_sampling_params,
|
||||
})
|
||||
}
|
||||
ReasoningStyle::AnthropicAdaptive => Some(ResolvedReasoning {
|
||||
style,
|
||||
level,
|
||||
reasoning_effort,
|
||||
thinking_level: None,
|
||||
budget_tokens: None,
|
||||
enable_thinking: None,
|
||||
suppress_sampling_params,
|
||||
}),
|
||||
ReasoningStyle::SiliconFlowEnableThinking => Some(ResolvedReasoning {
|
||||
style,
|
||||
level,
|
||||
reasoning_effort: None,
|
||||
thinking_level: None,
|
||||
budget_tokens,
|
||||
enable_thinking: Some(suppress_sampling_params),
|
||||
suppress_sampling_params,
|
||||
}),
|
||||
ReasoningStyle::None => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_style_from_profile(profile: &str) -> ReasoningStyle {
|
||||
match profile {
|
||||
"openai_reasoning_effort" => ReasoningStyle::OpenAIReasoningEffort,
|
||||
"openai_responses_reasoning" => ReasoningStyle::OpenAIResponsesReasoning,
|
||||
"glm_thinking" => ReasoningStyle::GlmThinking,
|
||||
"gemini_thinking_level" => ReasoningStyle::GeminiThinkingLevel,
|
||||
"gemini_thinking_budget" => ReasoningStyle::GeminiThinkingBudget,
|
||||
"anthropic_adaptive" => ReasoningStyle::AnthropicAdaptive,
|
||||
"anthropic_budget_tokens" => ReasoningStyle::AnthropicBudgetTokens,
|
||||
"siliconflow_enable_thinking" => ReasoningStyle::SiliconFlowEnableThinking,
|
||||
"none" => ReasoningStyle::None,
|
||||
// Backward-compatible aliases from the old model settings UI.
|
||||
"reasoning_effort" => ReasoningStyle::OpenAIReasoningEffort,
|
||||
"enable_thinking" => ReasoningStyle::SiliconFlowEnableThinking,
|
||||
_ => ReasoningStyle::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_budget_to_level(budget: u32) -> String {
|
||||
match budget {
|
||||
0 => "none",
|
||||
1..=2048 => "low",
|
||||
2049..=6144 => "medium",
|
||||
6145..=12288 => "high",
|
||||
_ => "xhigh",
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn level_to_effort(level: &str) -> Option<&'static str> {
|
||||
match level {
|
||||
"off" => Some("none"),
|
||||
"none" => Some("none"),
|
||||
"minimal" => Some("minimal"),
|
||||
"low" => Some("low"),
|
||||
"medium" => Some("medium"),
|
||||
"high" => Some("high"),
|
||||
"xhigh" => Some("xhigh"),
|
||||
"max" => Some("max"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn level_to_budget(level: &str) -> Option<u32> {
|
||||
match level {
|
||||
"off" | "none" => Some(0),
|
||||
"low" => Some(1024),
|
||||
"medium" => Some(4096),
|
||||
"high" => Some(8192),
|
||||
"xhigh" => Some(16384),
|
||||
"max" => Some(32768),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use boosapi_core::types::{ChatContent, ChatMessage};
|
||||
|
||||
fn request(thinking_level: Option<&str>, thinking_budget: Option<u32>) -> ChatRequest {
|
||||
ChatRequest {
|
||||
model: "gemini-3-flash-preview".to_string(),
|
||||
messages: vec![ChatMessage {
|
||||
role: "user".to_string(),
|
||||
content: ChatContent::Text("hi".to_string()),
|
||||
reasoning_content: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
}],
|
||||
stream: false,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
max_tokens: None,
|
||||
tools: None,
|
||||
thinking_budget,
|
||||
thinking_level: thinking_level.map(str::to_string),
|
||||
reasoning_profile: None,
|
||||
use_max_completion_tokens: None,
|
||||
thinking_param_style: None,
|
||||
extra_body: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_thinking_level_rejects_invalid_off_level() {
|
||||
let resolved = resolve_reasoning(
|
||||
&request(Some("off"), None),
|
||||
ReasoningStyle::GeminiThinkingLevel,
|
||||
);
|
||||
|
||||
assert!(resolved.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_treats_off_alias_as_none_effort() {
|
||||
let resolved = resolve_reasoning(
|
||||
&request(Some("off"), None),
|
||||
ReasoningStyle::OpenAIReasoningEffort,
|
||||
)
|
||||
.expect("reasoning config");
|
||||
|
||||
assert_eq!(resolved.reasoning_effort.as_deref(), Some("none"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glm_thinking_profile_is_resolved_without_openai_reasoning_effort() {
|
||||
let mut req = request(Some("high"), None);
|
||||
req.reasoning_profile = Some("glm_thinking".to_string());
|
||||
|
||||
let resolved = resolve_reasoning(&req, ReasoningStyle::OpenAIReasoningEffort)
|
||||
.expect("reasoning config");
|
||||
|
||||
assert_eq!(resolved.style, ReasoningStyle::GlmThinking);
|
||||
assert_eq!(resolved.level, "high");
|
||||
assert_eq!(resolved.reasoning_effort, None);
|
||||
assert!(resolved.suppress_sampling_params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::anthropic::AnthropicAdapter;
|
||||
use crate::cohere::CohereAdapter;
|
||||
use crate::custom_openai::CustomOpenAIAdapter;
|
||||
use crate::deepseek::DeepSeekAdapter;
|
||||
use crate::gemini::GeminiAdapter;
|
||||
use crate::glm::GLMAdapter;
|
||||
use crate::jina::JinaAdapter;
|
||||
use crate::openai::OpenAIAdapter;
|
||||
use crate::openai_responses::OpenAIResponsesAdapter;
|
||||
use crate::siliconflow::SiliconFlowAdapter;
|
||||
use crate::voyage::VoyageAdapter;
|
||||
use crate::xai::XAIAdapter;
|
||||
use crate::ProviderAdapter;
|
||||
|
||||
pub struct ProviderRegistry {
|
||||
adapters: HashMap<String, Box<dyn ProviderAdapter>>,
|
||||
}
|
||||
|
||||
impl ProviderRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
adapters: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(&mut self, provider_type: &str, adapter: Box<dyn ProviderAdapter>) {
|
||||
self.adapters.insert(provider_type.to_string(), adapter);
|
||||
}
|
||||
|
||||
pub fn get(&self, provider_type: &str) -> Option<&dyn ProviderAdapter> {
|
||||
self.adapters.get(provider_type).map(|a| a.as_ref())
|
||||
}
|
||||
|
||||
/// Creates a registry pre-populated with OpenAI, Anthropic, and Gemini adapters.
|
||||
pub fn create_default() -> Self {
|
||||
let mut registry = Self::new();
|
||||
registry.register("openai", Box::new(OpenAIAdapter::new()));
|
||||
registry.register("custom", Box::new(CustomOpenAIAdapter::new()));
|
||||
registry.register("openai_responses", Box::new(OpenAIResponsesAdapter::new()));
|
||||
registry.register("deepseek", Box::new(DeepSeekAdapter::new()));
|
||||
registry.register("xai", Box::new(XAIAdapter::new()));
|
||||
registry.register("glm", Box::new(GLMAdapter::new()));
|
||||
registry.register("siliconflow", Box::new(SiliconFlowAdapter::new()));
|
||||
registry.register("anthropic", Box::new(AnthropicAdapter::new()));
|
||||
registry.register("gemini", Box::new(GeminiAdapter::new()));
|
||||
registry.register("jina", Box::new(JinaAdapter::new()));
|
||||
registry.register("cohere", Box::new(CohereAdapter::new()));
|
||||
registry.register("voyage", Box::new(VoyageAdapter::new()));
|
||||
registry
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProviderRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::openai_compat::{OpenAICompatAdapter, OpenAICompatPolicy};
|
||||
use crate::reasoning::{ReasoningStyle, ResolvedReasoning};
|
||||
use crate::{build_http_client, ProviderAdapter, ProviderRequestContext};
|
||||
|
||||
pub struct SiliconFlowAdapter {
|
||||
inner: OpenAICompatAdapter<SiliconFlowPolicy>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SiliconFlowPolicy;
|
||||
|
||||
impl OpenAICompatPolicy for SiliconFlowPolicy {
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.siliconflow.cn/v1"
|
||||
}
|
||||
|
||||
fn error_label(&self) -> &'static str {
|
||||
"SiliconFlow API"
|
||||
}
|
||||
|
||||
fn default_reasoning_style(&self, _request: &ChatRequest) -> ReasoningStyle {
|
||||
ReasoningStyle::SiliconFlowEnableThinking
|
||||
}
|
||||
|
||||
fn normalize_reasoning_effort(
|
||||
&self,
|
||||
_request: &ChatRequest,
|
||||
_level: &str,
|
||||
_effort: String,
|
||||
) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn use_max_completion_tokens(&self, _request: &ChatRequest) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn extra_body_fields(&self, reasoning: Option<&ResolvedReasoning>) -> Map<String, Value> {
|
||||
let mut extra = Map::new();
|
||||
let Some(reasoning) = reasoning else {
|
||||
return extra;
|
||||
};
|
||||
|
||||
if let Some(enable_thinking) = reasoning.enable_thinking {
|
||||
extra.insert("enable_thinking".to_string(), Value::Bool(enable_thinking));
|
||||
}
|
||||
if let Some(thinking_budget) = reasoning.budget_tokens.filter(|v| *v > 0) {
|
||||
extra.insert(
|
||||
"thinking_budget".to_string(),
|
||||
serde_json::json!(thinking_budget),
|
||||
);
|
||||
}
|
||||
extra
|
||||
}
|
||||
}
|
||||
|
||||
impl SiliconFlowAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: OpenAICompatAdapter::new(SiliconFlowPolicy),
|
||||
client: crate::build_default_http_client()
|
||||
.expect("Failed to build default HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(ctx: &ProviderRequestContext) -> String {
|
||||
ctx.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| SiliconFlowPolicy.default_base_url().to_string())
|
||||
}
|
||||
|
||||
fn get_client(&self, ctx: &ProviderRequestContext) -> Result<reqwest::Client> {
|
||||
match &ctx.proxy_config {
|
||||
Some(c) if c.proxy_type.as_deref() != Some("none") => build_http_client(Some(c)),
|
||||
_ => Ok(self.client.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_siliconflow_rerank_body(request: &RerankRequest) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"model": request.model,
|
||||
"query": request.query,
|
||||
"documents": request.documents,
|
||||
"top_n": request.top_n,
|
||||
"return_documents": false,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NativeRerankResponse {
|
||||
results: Vec<NativeRerankResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NativeRerankResult {
|
||||
index: usize,
|
||||
relevance_score: f32,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_siliconflow_rerank_response(body: &str) -> Result<RerankResponse> {
|
||||
let parsed: NativeRerankResponse = serde_json::from_str(body)
|
||||
.map_err(|e| BoosApiError::Provider(format!("SiliconFlow rerank parse error: {e}")))?;
|
||||
Ok(RerankResponse {
|
||||
results: parsed
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|r| RerankResult {
|
||||
index: r.index,
|
||||
relevance_score: r.relevance_score,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for SiliconFlowAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
self.inner.chat(ctx, request).await
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
self.inner.chat_stream(ctx, request)
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
self.inner.list_models(ctx).await
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
self.inner.embed(ctx, request).await
|
||||
}
|
||||
|
||||
async fn rerank(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: RerankRequest,
|
||||
) -> Result<RerankResponse> {
|
||||
let url = format!("{}/rerank", Self::base_url(ctx));
|
||||
let resp = crate::apply_request_headers(
|
||||
self.get_client(ctx)?
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", ctx.api_key))
|
||||
.json(&build_siliconflow_rerank_body(&request)),
|
||||
ctx,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("SiliconFlow rerank request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"SiliconFlow rerank API error {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("SiliconFlow rerank body error: {e}")))?;
|
||||
parse_siliconflow_rerank_response(&text)
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
self.inner.validate_key(ctx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn siliconflow_body_uses_native_rerank_shape() {
|
||||
let body = build_siliconflow_rerank_body(&RerankRequest {
|
||||
model: "Qwen/Qwen3-Reranker-4B".into(),
|
||||
query: "capital".into(),
|
||||
documents: vec!["Beijing is the capital of China.".into()],
|
||||
top_n: 2,
|
||||
});
|
||||
|
||||
assert_eq!(body["model"], "Qwen/Qwen3-Reranker-4B");
|
||||
assert_eq!(body["query"], "capital");
|
||||
assert_eq!(body["documents"][0], "Beijing is the capital of China.");
|
||||
assert_eq!(body["top_n"], 2);
|
||||
assert_eq!(body["return_documents"], false);
|
||||
assert!(body.get("messages").is_none());
|
||||
assert!(body.get("input").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn siliconflow_parser_reads_relevance_score() {
|
||||
let parsed = parse_siliconflow_rerank_response(
|
||||
r#"{"results":[{"index":1,"relevance_score":0.88}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
RerankResponse {
|
||||
results: vec![RerankResult {
|
||||
index: 1,
|
||||
relevance_score: 0.88,
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use boosapi_core::error::{BoosApiError, Result};
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::{Stream, stream};
|
||||
use serde::Deserialize;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::{ProviderAdapter, ProviderRequestContext, build_http_client};
|
||||
|
||||
const DEFAULT_BASE_URL: &str = "https://api.voyageai.com/v1";
|
||||
|
||||
pub struct VoyageAdapter {
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl VoyageAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: crate::build_default_http_client()
|
||||
.expect("Failed to build default HTTP client"),
|
||||
}
|
||||
}
|
||||
|
||||
fn base_url(ctx: &ProviderRequestContext) -> String {
|
||||
ctx.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn get_client(&self, ctx: &ProviderRequestContext) -> Result<reqwest::Client> {
|
||||
match &ctx.proxy_config {
|
||||
Some(c) if c.proxy_type.as_deref() != Some("none") => build_http_client(Some(c)),
|
||||
_ => Ok(self.client.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported<T>() -> Result<T> {
|
||||
Err(BoosApiError::Provider(
|
||||
"Voyage provider only supports rerank in BoosApi".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn voyage_models(provider_id: &str) -> Vec<Model> {
|
||||
["rerank-2.5", "rerank-2.5-lite", "rerank-2", "rerank-2-lite"]
|
||||
.into_iter()
|
||||
.map(|model_id| Model {
|
||||
provider_id: provider_id.to_string(),
|
||||
model_id: model_id.to_string(),
|
||||
name: model_id.to_string(),
|
||||
group_name: None,
|
||||
model_type: ModelType::Rerank,
|
||||
capabilities: vec![],
|
||||
max_tokens: None,
|
||||
enabled: true,
|
||||
param_overrides: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn build_voyage_rerank_body(request: &RerankRequest) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"model": request.model,
|
||||
"query": request.query,
|
||||
"documents": request.documents,
|
||||
"top_k": request.top_n,
|
||||
"truncation": true,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NativeRerankResponse {
|
||||
results: Vec<NativeRerankResult>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NativeRerankResult {
|
||||
index: usize,
|
||||
relevance_score: f32,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_voyage_rerank_response(body: &str) -> Result<RerankResponse> {
|
||||
let parsed: NativeRerankResponse = serde_json::from_str(body)
|
||||
.map_err(|e| BoosApiError::Provider(format!("Voyage rerank parse error: {e}")))?;
|
||||
Ok(RerankResponse {
|
||||
results: parsed
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|r| RerankResult {
|
||||
index: r.index,
|
||||
relevance_score: r.relevance_score,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for VoyageAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
unsupported()
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
Box::pin(stream::once(async {
|
||||
Err(BoosApiError::Provider(
|
||||
"Voyage provider only supports rerank in BoosApi".into(),
|
||||
))
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
Ok(voyage_models(&ctx.provider_id))
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
_ctx: &ProviderRequestContext,
|
||||
_request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
unsupported()
|
||||
}
|
||||
|
||||
async fn rerank(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: RerankRequest,
|
||||
) -> Result<RerankResponse> {
|
||||
let url = format!("{}/rerank", Self::base_url(ctx));
|
||||
let resp = crate::apply_request_headers(
|
||||
self.get_client(ctx)?
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", ctx.api_key))
|
||||
.json(&build_voyage_rerank_body(&request)),
|
||||
ctx,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Voyage rerank request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(BoosApiError::Provider(format!(
|
||||
"Voyage rerank API error {status}: {text}"
|
||||
)));
|
||||
}
|
||||
|
||||
let text = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| BoosApiError::Provider(format!("Voyage rerank body error: {e}")))?;
|
||||
parse_voyage_rerank_response(&text)
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
let request = RerankRequest {
|
||||
model: "rerank-2.5-lite".to_string(),
|
||||
query: "test".to_string(),
|
||||
documents: vec!["test".to_string()],
|
||||
top_n: 1,
|
||||
};
|
||||
self.rerank(ctx, request).await.map(|_| true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn voyage_body_uses_native_top_k_and_truncation() {
|
||||
let body = build_voyage_rerank_body(&RerankRequest {
|
||||
model: "rerank-2.5".into(),
|
||||
query: "capital".into(),
|
||||
documents: vec!["doc".into()],
|
||||
top_n: 4,
|
||||
});
|
||||
|
||||
assert_eq!(body["model"], "rerank-2.5");
|
||||
assert_eq!(body["top_k"], 4);
|
||||
assert_eq!(body["truncation"], true);
|
||||
assert!(body.get("top_n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voyage_parser_reads_relevance_score() {
|
||||
let parsed =
|
||||
parse_voyage_rerank_response(r#"{"results":[{"index":0,"relevance_score":0.72}]}"#)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
RerankResponse {
|
||||
results: vec![RerankResult {
|
||||
index: 0,
|
||||
relevance_score: 0.72,
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use boosapi_core::error::Result;
|
||||
use boosapi_core::types::*;
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::openai_compat::{OpenAICompatAdapter, OpenAICompatPolicy};
|
||||
use crate::reasoning::{ReasoningStyle, ResolvedReasoning};
|
||||
use crate::{ProviderAdapter, ProviderRequestContext};
|
||||
|
||||
pub struct XAIAdapter {
|
||||
inner: OpenAICompatAdapter<XAIPolicy>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct XAIPolicy;
|
||||
|
||||
fn supports_reasoning_effort(model: &str) -> bool {
|
||||
model.to_ascii_lowercase().starts_with("grok-4.3")
|
||||
}
|
||||
|
||||
impl OpenAICompatPolicy for XAIPolicy {
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.x.ai/v1"
|
||||
}
|
||||
|
||||
fn error_label(&self) -> &'static str {
|
||||
"xAI API"
|
||||
}
|
||||
|
||||
fn default_reasoning_style(&self, request: &ChatRequest) -> ReasoningStyle {
|
||||
if supports_reasoning_effort(&request.model) {
|
||||
ReasoningStyle::OpenAIReasoningEffort
|
||||
} else {
|
||||
ReasoningStyle::None
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_reasoning_effort(
|
||||
&self,
|
||||
request: &ChatRequest,
|
||||
_level: &str,
|
||||
effort: String,
|
||||
) -> Option<String> {
|
||||
if !supports_reasoning_effort(&request.model) {
|
||||
return None;
|
||||
}
|
||||
matches!(effort.as_str(), "none" | "low" | "medium" | "high").then_some(effort)
|
||||
}
|
||||
|
||||
fn use_max_completion_tokens(&self, _request: &ChatRequest) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn suppress_sampling_params(&self, _reasoning: Option<&ResolvedReasoning>) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl XAIAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: OpenAICompatAdapter::new(XAIPolicy),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdapter for XAIAdapter {
|
||||
async fn chat(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Result<ChatResponse> {
|
||||
self.inner.chat(ctx, request).await
|
||||
}
|
||||
|
||||
fn chat_stream(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: ChatRequest,
|
||||
) -> Pin<Box<dyn Stream<Item = Result<ChatStreamChunk>> + Send>> {
|
||||
self.inner.chat_stream(ctx, request)
|
||||
}
|
||||
|
||||
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
|
||||
self.inner.list_models(ctx).await
|
||||
}
|
||||
|
||||
async fn embed(
|
||||
&self,
|
||||
ctx: &ProviderRequestContext,
|
||||
request: EmbedRequest,
|
||||
) -> Result<EmbedResponse> {
|
||||
self.inner.embed(ctx, request).await
|
||||
}
|
||||
|
||||
async fn validate_key(&self, ctx: &ProviderRequestContext) -> Result<bool> {
|
||||
self.inner.validate_key(ctx).await
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user