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:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -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
}
}