use crate::providers::reasoning_roundtrip;
use crate::providers::{ScopedCallError, ensure_chat_completions_url, provider_routing_json};
use crate::retry::{FailureClass, RetryFailureRecord};
use crate::util::error::{HttpError, retry_after_header};
use crate::util::json::try_repair_json;
use crate::{
ChatMessage, ChatRequest as ProviderChatRequest, ChatResponse as ProviderChatResponse,
ChatRole, Provider, ProviderUsage, Reasoning, ToolCall as ProviderToolCall, ToolSpec,
};
use async_trait::async_trait;
use futures_util::StreamExt;
use reqwest::{
Client, RequestBuilder,
header::{HeaderMap, HeaderValue},
};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
pub(crate) struct OpenAiCompatibleProvider {
pub name: String,
pub base_url: String,
pub credential: Option<String>,
timeout_secs: u64,
extra_headers: std::collections::HashMap<String, String>,
http_client: OnceLock<Client>,
http_client_scoped: OnceLock<Client>,
}
impl OpenAiCompatibleProvider {
#[must_use]
pub fn new(name: &str, base_url: &str, credential: Option<&str>) -> Self {
Self {
name: name.to_string(),
base_url: base_url.trim_end_matches('/').to_string(),
credential: credential.map(ToString::to_string),
timeout_secs: 120,
extra_headers: std::collections::HashMap::new(),
http_client: OnceLock::new(),
http_client_scoped: OnceLock::new(),
}
}
#[must_use]
pub fn with_extra_headers(
mut self,
headers: std::collections::HashMap<String, String>,
) -> Self {
self.extra_headers = headers;
self
}
fn build_client(&self, timeout: Option<Duration>) -> Client {
crate::util::http::install_ring_provider();
let mut builder = Client::builder().connect_timeout(Duration::from_secs(10));
if let Some(timeout) = timeout {
builder = builder.timeout(timeout);
}
if !self.extra_headers.is_empty() {
let mut headers = HeaderMap::new();
for (key, value) in &self.extra_headers {
match (
reqwest::header::HeaderName::from_bytes(key.as_bytes()),
HeaderValue::from_str(value),
) {
(Ok(name), Ok(val)) => {
headers.insert(name, val);
}
_ => {
tracing::warn!(header = key, "Skipping invalid extra header name or value");
}
}
}
builder = builder.default_headers(headers);
}
builder
.build()
.expect("Failed to build HTTP client — check TLS/network configuration")
}
pub(crate) fn http_client(&self) -> &Client {
self.http_client
.get_or_init(|| self.build_client(Some(Duration::from_secs(self.timeout_secs))))
}
pub(crate) fn http_client_scoped(&self) -> &Client {
self.http_client_scoped
.get_or_init(|| self.build_client(None))
}
}
#[derive(Debug, Serialize)]
struct ChatCompletionRequest {
model: String,
messages: Vec<NativeMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<String>,
#[serde(flatten)]
extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
struct ApiChatResponse {
choices: Vec<Choice>,
#[serde(default)]
usage: Option<UsageInfo>,
#[serde(default, deserialize_with = "opt_field")]
system_fingerprint: Option<String>,
#[serde(default, deserialize_with = "opt_field")]
provider: Option<String>,
}
#[derive(Debug, Deserialize)]
struct UsageInfo {
#[serde(default)]
prompt_tokens: Option<u64>,
#[serde(default)]
completion_tokens: Option<u64>,
#[serde(default)]
prompt_tokens_details: Option<PromptTokensDetails>,
#[serde(default)]
prompt_cache_hit_tokens: Option<u64>,
#[serde(default)]
prompt_cache_miss_tokens: Option<u64>,
#[serde(default, deserialize_with = "opt_field")]
cost: Option<f64>,
#[serde(default, deserialize_with = "opt_field")]
cost_details: Option<serde_json::Value>,
}
#[allow(
clippy::unnecessary_wraps,
reason = "Result is the deserialize_with contract"
)]
fn opt_field<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned,
{
Ok(serde_json::Value::deserialize(de)
.ok()
.and_then(|v| serde_json::from_value(v).ok()))
}
#[derive(Debug, Deserialize)]
struct PromptTokensDetails {
#[serde(default)]
cached_tokens: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct Choice {
message: ResponseMessage,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct ResponseMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
reasoning_content: Option<String>,
#[serde(default)]
reasoning: Option<String>,
#[serde(default)]
reasoning_details: Option<serde_json::Value>,
#[serde(default)]
tool_calls: Option<Vec<ApiToolCall>>,
}
impl ResponseMessage {
fn effective_content_optional(&self) -> Option<String> {
self.content
.as_ref()
.filter(|c| !c.is_empty())
.and_then(|c| crate::providers::reasoning::strip_think_tags(c))
}
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ApiToolCall {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
#[serde(rename = "type")]
#[serde(default, skip_serializing_if = "Option::is_none")]
kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
function: Option<ApiToolCallFunction>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
arguments: Option<String>,
#[serde(
rename = "parameters",
default,
skip_serializing_if = "Option::is_none"
)]
parameters: Option<serde_json::Value>,
}
#[must_use]
pub(crate) fn resolve_tool_call_name(
function_name: Option<&str>,
direct_name: Option<&str>,
) -> Option<String> {
function_name
.filter(|n| !n.is_empty())
.or_else(|| direct_name.filter(|n| !n.is_empty()))
.map(String::from)
}
#[must_use]
pub(crate) fn resolve_tool_call_arguments(
function_arguments: Option<&str>,
direct_arguments: Option<&str>,
parameters: Option<&serde_json::Value>,
) -> Option<String> {
if let Some(args) = function_arguments.filter(|a| !a.is_empty()) {
return Some(args.to_string());
}
if let Some(args) = direct_arguments.filter(|a| !a.is_empty()) {
return Some(args.to_string());
}
parameters.and_then(|params| serde_json::to_string(params).ok())
}
impl ApiToolCall {
fn function_name(&self) -> Option<String> {
resolve_tool_call_name(
self.function.as_ref().and_then(|f| f.name.as_deref()),
self.name.as_deref(),
)
}
fn function_arguments(&self) -> Option<String> {
resolve_tool_call_arguments(
self.function.as_ref().and_then(|f| f.arguments.as_deref()),
self.arguments.as_deref(),
self.parameters.as_ref(),
)
}
}
#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct ApiToolCallFunction {
#[serde(default)]
pub(crate) name: Option<String>,
#[serde(default)]
pub(crate) arguments: Option<String>,
}
#[derive(Debug, Serialize)]
struct NativeMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<MessageContent>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<ApiToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_details: Option<serde_json::Value>,
}
impl NativeMessage {
#[cfg(test)]
fn user(content: &str) -> Self {
NativeMessage {
role: "user".into(),
content: Some(MessageContent::Text(content.into())),
tool_call_id: None,
tool_calls: None,
reasoning_content: None,
reasoning: None,
reasoning_details: None,
}
}
}
#[must_use]
pub(crate) fn parse_image_markers(content: &str) -> (String, Vec<String>) {
let mut refs: Vec<String> = Vec::new();
let cleaned = crate::util::MEDIA_MARKER_RE
.replace_all(content, |caps: ®ex::Captures| {
let (kind, path) = crate::util::parse_media_marker(caps);
let path = path.trim();
if kind == "IMAGE" {
refs.push(path.to_string());
String::new()
} else {
caps.get_match().as_str().to_string()
}
})
.to_string();
(cleaned.trim().to_string(), refs)
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum MessageContent {
Text(String),
Parts(Vec<MessagePart>),
Null,
}
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub(crate) enum MessagePart {
Text { text: String },
ImageUrl { image_url: ImageUrlPart },
}
#[derive(Debug, Serialize)]
pub(crate) struct ImageUrlPart {
pub url: String,
}
pub(crate) fn to_message_content(
role: ChatRole,
content: &str,
allow_user_image_parts: bool,
) -> MessageContent {
if role != ChatRole::User || !allow_user_image_parts {
return MessageContent::Text(content.to_string());
}
if !content.contains("[IMAGE:") {
return MessageContent::Text(content.to_string());
}
let (cleaned_text, image_refs) = parse_image_markers(content);
if image_refs.is_empty() {
return MessageContent::Text(content.to_string());
}
let mut parts = Vec::with_capacity(image_refs.len() + 1);
let trimmed_text = cleaned_text.trim();
if !trimmed_text.is_empty() {
parts.push(MessagePart::Text {
text: trimmed_text.to_string(),
});
}
for image_ref in image_refs {
parts.push(MessagePart::ImageUrl {
image_url: ImageUrlPart { url: image_ref },
});
}
MessageContent::Parts(parts)
}
impl OpenAiCompatibleProvider {
fn convert_tool_specs(tools: Option<&[ToolSpec]>) -> Option<Vec<serde_json::Value>> {
let items = tools?;
let converted: Vec<_> = items
.iter()
.map(|tool| {
let params = tool.parameters.clone();
serde_json::json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": params,
}
})
})
.collect();
if converted.is_empty() {
None
} else {
Some(converted)
}
}
fn convert_messages_for_native(
messages: &[ChatMessage],
allow_user_image_parts: bool,
) -> Vec<NativeMessage> {
messages
.iter()
.map(|message| {
let decoded = crate::session::decode_native_history_message(message);
let Some((role, content, tool_call_id, tool_calls, reasoning)) =
decoded.map(|msg| match msg {
crate::session::DecodedNativeHistoryMessage::Assistant {
content,
tool_calls,
reasoning,
} => (
ChatRole::Assistant.to_string(),
content,
None, tool_calls,
reasoning,
),
crate::session::DecodedNativeHistoryMessage::ToolResult {
tool_call_id,
content,
} => (
ChatRole::Tool.to_string(),
Some(content),
Some(tool_call_id),
None, None, ),
})
else {
return NativeMessage {
role: message.role.to_string(),
content: Some(to_message_content(
message.role,
&message.content,
allow_user_image_parts,
)),
tool_call_id: None,
tool_calls: None,
reasoning: None,
reasoning_content: None,
reasoning_details: None,
};
};
let has_tool_calls = tool_calls.as_ref().is_some_and(|c| !c.is_empty());
let (r_reasoning, r_content, r_details) =
reasoning_roundtrip::native_reasoning_triple_for_replay(
reasoning.as_ref(),
has_tool_calls,
);
let tool_calls = tool_calls.map(|tc| {
tc.into_iter()
.map(|tc| ApiToolCall {
id: Some(tc.id),
kind: Some("function".to_string()),
function: Some(ApiToolCallFunction {
name: Some(tc.name),
arguments: Some(
serde_json::to_string(&tc.arguments)
.unwrap_or_else(|_| "{}".into()),
),
}),
name: None,
arguments: None,
parameters: None,
})
.collect()
});
let has_reasoning =
r_content.is_some() || r_reasoning.is_some() || r_details.is_some();
let content = match (&content, has_reasoning, has_tool_calls) {
(Some(s), _, _) => Some(MessageContent::Text(s.clone())),
(None, true, true) => Some(MessageContent::Null),
(None, true, false) => Some(MessageContent::Text(String::new())),
(None, false, _) => None,
};
NativeMessage {
role,
content,
tool_call_id,
tool_calls,
reasoning: r_reasoning,
reasoning_content: r_content,
reasoning_details: r_details,
}
})
.collect()
}
}
#[must_use]
fn parse_tool_call_arguments(name: &str, arguments: &str) -> serde_json::Value {
serde_json::from_str(arguments).unwrap_or_else(|parse_err| {
if let Some(value) = try_repair_json::<serde_json::Value>(arguments) {
tracing::debug!(
function = %name,
original_error = %parse_err,
"Repaired malformed JSON in tool-call arguments"
);
return value;
}
tracing::debug!(
function = %name,
arguments = %arguments,
error = %parse_err,
"Invalid JSON in tool-call arguments, using empty object"
);
serde_json::json!({})
})
}
#[must_use]
fn make_provider_tool_call(id: Option<String>, name: String, arguments: &str) -> ProviderToolCall {
let arguments = parse_tool_call_arguments(&name, arguments);
ProviderToolCall {
id: id.unwrap_or_else(crate::generate_id),
name,
arguments,
}
}
#[must_use]
fn normalize_cache_tokens(
cached_tokens: Option<u64>,
hit_tokens: Option<u64>,
miss_tokens: Option<u64>,
prompt_tokens: Option<u64>,
) -> (Option<u64>, Option<u64>) {
let cached = cached_tokens.or(hit_tokens);
let miss = miss_tokens.or_else(|| match (cached, prompt_tokens) {
(Some(c), Some(p)) => p.checked_sub(c),
_ => None,
});
(cached, miss)
}
impl OpenAiCompatibleProvider {
fn parse_native_response(
message: ResponseMessage,
usage: Option<ProviderUsage>,
finish_reason: Option<String>,
upstream_provider: Option<String>,
system_fingerprint: Option<String>,
) -> ProviderChatResponse {
let text = message.effective_content_optional();
let reasoning = Reasoning::from_optional_parts(
message.reasoning,
message.reasoning_content,
message.reasoning_details,
);
let tool_calls = message
.tool_calls
.unwrap_or_default()
.into_iter()
.filter_map(|tc| {
let name = tc.function_name()?;
let arguments = tc.function_arguments().unwrap_or_default();
Some(make_provider_tool_call(tc.id, name, &arguments))
})
.collect::<Vec<_>>();
ProviderChatResponse {
text,
tool_calls,
usage,
reasoning,
finish_reason,
upstream_provider,
system_fingerprint,
}
}
fn finalize_response<E>(
&self,
model: &str,
native_response: ApiChatResponse,
no_response: impl FnOnce() -> E,
) -> Result<ProviderChatResponse, E> {
let usage = native_response.usage.map(|u| {
let (cached, miss) = normalize_cache_tokens(
u.prompt_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens),
u.prompt_cache_hit_tokens,
u.prompt_cache_miss_tokens,
u.prompt_tokens,
);
ProviderUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
cached_input_tokens: cached,
cache_miss_tokens: miss,
cost: u.cost,
cost_details: u.cost_details,
}
});
let upstream_provider = native_response.provider.clone();
let choice = native_response
.choices
.into_iter()
.next()
.ok_or_else(no_response)?;
let finish_reason = choice.finish_reason;
let message = choice.message;
let result = Self::parse_native_response(
message,
usage,
finish_reason,
upstream_provider,
native_response.system_fingerprint,
);
if !result.tool_calls.is_empty() && result.reasoning.is_none() {
tracing::debug!(
provider = %self.name,
model,
"tool turn: parsed response has no reasoning fields",
);
}
Ok(result)
}
fn build_http_request_with_client(
&self,
client: &Client,
request: &ProviderChatRequest,
) -> RequestBuilder {
let native =
Self::convert_messages_for_native(&request.messages, request.allow_image_parts);
let tool_specs = Self::convert_tool_specs(request.tools.as_deref());
let mut extra = serde_json::Map::new();
if let Some(order) = &request.provider_order
&& let Some(routing) = provider_routing_json(order)
&& crate::config::is_default_endpoint(&self.base_url)
{
extra.insert("provider".to_string(), routing);
}
match reasoning_fields_for_request(
&self.base_url,
&request.model,
request.reasoning_effort.as_deref(),
) {
ReasoningFields::Effort(value) => {
extra.insert("reasoning_effort".to_string(), serde_json::json!(value));
}
ReasoningFields::ThinkingEnabled => {
extra.insert(
"thinking".to_string(),
serde_json::json!({ "type": "enabled" }),
);
}
ReasoningFields::Omit => {}
}
let payload = ChatCompletionRequest {
model: request.model.clone(),
messages: native,
max_tokens: request.max_tokens,
tool_choice: tool_specs.as_ref().map(|_| "auto".to_string()),
tools: tool_specs,
extra,
};
let url = ensure_chat_completions_url(&self.base_url);
let builder = client.post(url).json(&payload);
self.attach_auth_header(builder)
}
fn attach_auth_header(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(ref credential) = self.credential {
builder = builder.header("Authorization", format!("Bearer {credential}"));
}
builder
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum ReasoningFields {
Effort(String),
ThinkingEnabled,
Omit,
}
#[must_use]
fn reasoning_fields_for_request(
endpoint: &str,
model: &str,
effort: Option<&str>,
) -> ReasoningFields {
let Some(effort) = effort.filter(|e| !e.is_empty()) else {
return ReasoningFields::Omit;
};
if crate::config::is_default_endpoint(endpoint) {
return ReasoningFields::Effort(effort.to_string());
}
translate_for_custom_endpoint(model, effort)
}
#[must_use]
fn translate_for_custom_endpoint(model: &str, effort: &str) -> ReasoningFields {
let family = detect_family(model);
match family {
ReasoningFamily::Mimo => ReasoningFields::ThinkingEnabled,
ReasoningFamily::MiniMax => ReasoningFields::Omit,
_ => {
let value = match (family, effort) {
(ReasoningFamily::Hy3 | ReasoningFamily::Gemini, "xhigh") => "high",
(ReasoningFamily::Hy3, "high") => "low",
(ReasoningFamily::Gemini, "high") => "medium",
(
ReasoningFamily::Deepseek
| ReasoningFamily::Kimi
| ReasoningFamily::Glm
| ReasoningFamily::Fallback,
"xhigh",
) => "max",
_ => effort,
};
ReasoningFields::Effort(value.to_string())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReasoningFamily {
Deepseek,
Kimi,
Glm,
Hy3,
MuseSpark,
Grok,
Gemini,
Mimo,
MiniMax,
Fallback,
}
#[must_use]
fn detect_family(model: &str) -> ReasoningFamily {
let m = model.to_ascii_lowercase();
if m.contains("deepseek") {
ReasoningFamily::Deepseek
} else if m.contains("kimi") {
ReasoningFamily::Kimi
} else if m.contains("glm") {
ReasoningFamily::Glm
} else if m.contains("hy3") {
ReasoningFamily::Hy3
} else if m.contains("muse-spark") {
ReasoningFamily::MuseSpark
} else if m.contains("grok") {
ReasoningFamily::Grok
} else if m.contains("gemini") {
ReasoningFamily::Gemini
} else if m.contains("mimo") {
ReasoningFamily::Mimo
} else if m.contains("minimax") {
ReasoningFamily::MiniMax
} else {
ReasoningFamily::Fallback
}
}
enum BodyReadOutcome {
Complete(Vec<u8>),
Failed {
partial: Vec<u8>,
message: String,
class: FailureClass,
},
}
async fn read_body_idle(
response: reqwest::Response,
idle_timeout: Duration,
deadline: Instant,
) -> BodyReadOutcome {
let mut body = Vec::new();
let mut stream = response.bytes_stream();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return BodyReadOutcome::Failed {
partial: body,
message: "response body read exceeded remaining operation budget".to_string(),
class: FailureClass::WallClockExceeded,
};
}
let wait_bound = idle_timeout.min(remaining);
let next_chunk = crate::shutdown::race_shutdown(stream.next());
let chunk = match tokio::time::timeout(wait_bound, next_chunk).await {
Err(_) => {
if Instant::now() >= deadline {
return BodyReadOutcome::Failed {
partial: body,
message: "response body read exceeded remaining operation budget"
.to_string(),
class: FailureClass::WallClockExceeded,
};
}
return BodyReadOutcome::Failed {
partial: body,
message: format!(
"response body read idle timeout after {idle_timeout:?} \
with no data flowing"
),
class: FailureClass::TruncatedEnvelope,
};
}
Ok(Err(_)) => {
return BodyReadOutcome::Failed {
partial: body,
message: "shutdown during response body read".to_string(),
class: FailureClass::Shutdown,
};
}
Ok(Ok(Some(chunk))) => chunk,
Ok(Ok(None)) => break,
};
match chunk {
Ok(bytes) => body.extend_from_slice(&bytes),
Err(e) => {
return BodyReadOutcome::Failed {
partial: body,
message: format!("{e}"),
class: FailureClass::TruncatedEnvelope,
};
}
}
}
BodyReadOutcome::Complete(body)
}
fn envelope_telemetry(body: &str) -> Option<String> {
if let Ok(native) = serde_json::from_str::<ApiChatResponse>(body) {
return native.choices.first().and_then(|c| c.finish_reason.clone());
}
if let Ok(value) = serde_json::from_str::<serde_json::Value>(body) {
return value
.pointer("/choices/0/finish_reason")
.and_then(serde_json::Value::as_str)
.map(str::to_string);
}
None
}
#[async_trait]
impl Provider for OpenAiCompatibleProvider {
async fn warmup(&self) -> anyhow::Result<()> {
let url = ensure_chat_completions_url(&self.base_url);
let builder = self.http_client().get(&url);
let _ = self.attach_auth_header(builder).send().await?;
Ok(())
}
async fn chat_scoped(
&self,
request: ProviderChatRequest,
idle_timeout: Duration,
deadline: Instant,
) -> Result<ProviderChatResponse, ScopedCallError> {
let req_builder = self.build_http_request_with_client(self.http_client_scoped(), &request);
let model = request.model;
let remaining = deadline.saturating_duration_since(Instant::now());
let send_timeout = idle_timeout.min(remaining);
let send_fut = crate::shutdown::race_shutdown(req_builder.send());
let response = match tokio::time::timeout(send_timeout, send_fut).await {
Err(_) => {
let budget_expired = remaining <= idle_timeout;
let err = if budget_expired {
anyhow::anyhow!("{} request exceeded remaining operation budget", self.name)
} else {
anyhow::anyhow!(
"{} request timed out waiting for response headers \
(idle timeout {idle_timeout:?})",
self.name
)
};
let class = if budget_expired {
FailureClass::WallClockExceeded
} else {
FailureClass::Transport
};
return Err(scoped_simple_error(err, class));
}
Ok(Err(_)) => {
let err = anyhow::anyhow!("shutdown during request");
return Err(scoped_simple_error(err, FailureClass::Shutdown));
}
Ok(Ok(Err(e))) => {
let err = anyhow::Error::from(e).context(format!("{} transport error", self.name));
return Err(scoped_simple_error(err, FailureClass::Transport));
}
Ok(Ok(Ok(resp))) => resp,
};
let content_length = response.content_length();
if !response.status().is_success() {
return Err(scoped_http_error(self, response, idle_timeout, deadline).await);
}
let (body_bytes, read_failure) =
match read_body_idle(response, idle_timeout, deadline).await {
BodyReadOutcome::Complete(bytes) => (bytes, None),
BodyReadOutcome::Failed {
partial,
message,
class,
} => (partial, Some((message, class))),
};
let body_str = String::from_utf8_lossy(&body_bytes).into_owned();
let actual_len = body_bytes.len();
if let Some((read_msg, class)) = read_failure {
let err = anyhow::anyhow!("{} error reading response body: {read_msg}", self.name);
return Err(scoped_metadata_error(err, class, &body_str, None));
}
let native_response: ApiChatResponse = match serde_json::from_str(&body_str) {
Ok(native) => native,
Err(e) => {
let truncated = content_length.is_some_and(|cl| cl != actual_len as u64);
let class = if truncated {
FailureClass::TruncatedEnvelope
} else {
FailureClass::Parse
};
let err = anyhow::anyhow!(
"{} chat completions parse error: {e}; body ({}): {:.500}",
self.name,
body_str.len(),
body_str
);
return Err(scoped_metadata_error(err, class, &body_str, None));
}
};
self.finalize_response(&model, native_response, || {
scoped_simple_error(
anyhow::anyhow!("No response from {}", self.name),
FailureClass::NoResponse,
)
})
}
}
fn scoped_simple_error(err: anyhow::Error, class: FailureClass) -> ScopedCallError {
let record = RetryFailureRecord::new_simple(class, &err, None);
ScopedCallError::new(err, record, class)
}
fn scoped_metadata_error(
err: anyhow::Error,
class: FailureClass,
body: &str,
retry_after_ms: Option<u64>,
) -> ScopedCallError {
let record =
RetryFailureRecord::with_metadata(class, &err, envelope_telemetry(body), retry_after_ms);
ScopedCallError::new(err, record, class)
}
async fn scoped_http_error(
provider: &OpenAiCompatibleProvider,
response: reqwest::Response,
idle_timeout: Duration,
deadline: Instant,
) -> ScopedCallError {
let status = response.status().as_u16();
let retry_after_ms = retry_after_header(response.headers());
let (body_bytes, message) = match read_body_idle(response, idle_timeout, deadline).await {
BodyReadOutcome::Complete(bytes) => (bytes, None),
BodyReadOutcome::Failed {
partial, message, ..
} => (partial, Some(message)),
};
let body = String::from_utf8_lossy(&body_bytes).into_owned();
let http_err = HttpError {
status,
body: body.clone(),
context: provider.name.clone(),
};
let inner = anyhow::Error::from(http_err);
let body_read_failed = message.is_some();
let inner = match message {
None => inner,
Some(read_msg) => inner.context(format!(
"{} error reading response body: {read_msg}",
provider.name
)),
};
let class = crate::providers::failure_class(
crate::providers::reliable::classify_err(&inner),
body_read_failed,
);
scoped_metadata_error(inner, class, &body, retry_after_ms)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::providers::test_request;
#[tokio::test]
async fn chat_without_key_attempts_request() {
let p = OpenAiCompatibleProvider::new("Local", "http://127.0.0.1:1", None);
let result = p
.chat(test_request(vec![ChatMessage::user("hello")], None))
.await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
!err_msg.contains("API key not set"),
"should not get credential error, got: {err_msg}"
);
}
#[test]
fn provider_routing_block_suppressed_for_custom_endpoint() {
let mut request = test_request(vec![ChatMessage::user("hello")], None);
request.provider_order = Some("DeepSeek".to_string());
request.reasoning_effort = Some("xhigh".to_string());
let body = |p: &OpenAiCompatibleProvider| {
let req = p
.build_http_request_with_client(p.http_client_scoped(), &request)
.build()
.expect("request builds");
String::from_utf8(
req.body()
.expect("full body")
.as_bytes()
.expect("bytes")
.to_vec(),
)
.expect("utf8 body")
};
let or_provider = OpenAiCompatibleProvider::new(
"OpenRouter",
crate::config::DEFAULT_PROVIDER_ENDPOINT,
Some("sk-or"),
);
let or_body = body(&or_provider);
assert!(
or_body.contains("\"provider\""),
"default endpoint must send the routing block: {or_body}"
);
assert!(
or_body.contains("DeepSeek"),
"routing order must be inside the block: {or_body}"
);
assert!(
or_body.contains("xhigh"),
"reasoning_effort must be sent to the default endpoint: {or_body}"
);
let custom_provider =
OpenAiCompatibleProvider::new("Custom endpoint", "http://localhost:8080/v1", None);
let custom_body = body(&custom_provider);
assert!(
!custom_body.contains("\"provider\""),
"custom endpoint must not receive the OpenRouter routing block: {custom_body}"
);
assert!(
custom_body.contains("\"reasoning_effort\":\"max\""),
"custom endpoints must receive the translated reasoning effort (fallback 'test' model: xhigh→max): {custom_body}"
);
assert!(
!custom_body.contains("xhigh"),
"custom endpoints must not receive the untranslated effort: {custom_body}"
);
}
#[test]
fn reasoning_translation_table_for_custom_endpoints() {
use ReasoningFields::{Effort, Omit, ThinkingEnabled};
let cases: &[(&str, &str, &str, ReasoningFields)] = &[
(
"deepseek v4 xhigh",
"deepseek/deepseek-v4-pro-0813",
"xhigh",
Effort("max".into()),
),
(
"deepseek v4 high",
"deepseek/deepseek-v4-pro-0813",
"high",
Effort("high".into()),
),
(
"deepseek v3 (older sibling, family match)",
"deepseek/deepseek-v3",
"xhigh",
Effort("max".into()),
),
(
"kimi k3 xhigh",
"moonshotai/kimi-k3",
"xhigh",
Effort("max".into()),
),
(
"kimi k3 high",
"moonshotai/kimi-k3",
"high",
Effort("high".into()),
),
(
"glm 5.3 xhigh",
"zai-org/glm-5.3",
"xhigh",
Effort("max".into()),
),
(
"glm 5.3 high",
"zai-org/glm-5.3",
"high",
Effort("high".into()),
),
(
"hy3 xhigh",
"tencent/hunyuan-hy3",
"xhigh",
Effort("high".into()),
),
(
"hy3 high",
"tencent/hunyuan-hy3",
"high",
Effort("low".into()),
),
(
"muse-spark xhigh",
"meta/muse-spark-1.2",
"xhigh",
Effort("xhigh".into()),
),
(
"muse-spark high",
"meta/muse-spark-1.2",
"high",
Effort("high".into()),
),
(
"grok xhigh",
"x-ai/grok-4.6",
"xhigh",
Effort("xhigh".into()),
),
("grok high", "x-ai/grok-4.6", "high", Effort("high".into())),
(
"gemini xhigh",
"google/gemini-3.7-flash",
"xhigh",
Effort("high".into()),
),
(
"gemini high",
"google/gemini-3.7-flash",
"high",
Effort("medium".into()),
),
("mimo xhigh", "xiaomi/mimo", "xhigh", ThinkingEnabled),
(
"mimo medium (field-shape dominates)",
"xiaomi/mimo-7b",
"medium",
ThinkingEnabled,
),
("minimax m3 xhigh", "minimax/minimax-m3", "xhigh", Omit),
(
"minimax m3 low (field-shape dominates)",
"minimax/minimax-m3",
"low",
Omit,
),
(
"minimax m2.1 (older sibling, family match)",
"minimax/minimax-m2.1",
"high",
Omit,
),
(
"fallback gpt xhigh",
"openai/gpt-5.6",
"xhigh",
Effort("max".into()),
),
(
"fallback high",
"openai/gpt-4.1",
"high",
Effort("high".into()),
),
(
"fallback medium passes through",
"openai/gpt-5.6",
"medium",
Effort("medium".into()),
),
(
"fallback none passes through",
"openai/gpt-5.6",
"none",
Effort("none".into()),
),
(
"deepseek medium passes through",
"deepseek/deepseek-v4",
"medium",
Effort("medium".into()),
),
(
"gemini minimal passes through",
"google/gemini-3.7-flash",
"minimal",
Effort("minimal".into()),
),
(
"DeepSeek case-insensitive",
"DEEPSEEK/deepseek-v4",
"xhigh",
Effort("max".into()),
),
(
"deepseek-hy3 first-match deepseek",
"deepseek-hy3",
"xhigh",
Effort("max".into()),
),
];
let custom_endpoint = "http://localhost:8080/v1";
for (label, model, effort, expected) in cases {
let actual = reasoning_fields_for_request(custom_endpoint, model, Some(effort));
assert_eq!(&actual, expected, "{label}: model={model} effort={effort}");
}
assert_eq!(
reasoning_fields_for_request(custom_endpoint, "deepseek/deepseek-v4", None),
Omit
);
assert_eq!(
reasoning_fields_for_request(custom_endpoint, "deepseek/deepseek-v4", Some("")),
Omit
);
assert_eq!(
reasoning_fields_for_request(
crate::config::DEFAULT_PROVIDER_ENDPOINT,
"deepseek/deepseek-v4",
None
),
Omit
);
let or_endpoint = crate::config::DEFAULT_PROVIDER_ENDPOINT;
for (label, model, effort, _) in cases {
assert_eq!(
reasoning_fields_for_request(or_endpoint, model, Some(effort)),
Effort(effort.to_string()),
"OpenRouter must pass effort through unchanged: {label} model={model}"
);
}
}
#[test]
#[expect(clippy::type_complexity)]
fn resolve_tool_call_name_cases() {
let cases: &[(&str, Option<&str>, Option<&str>, Option<&str>)] = &[
(
"function wins",
Some("func_name"),
Some("direct_name"),
Some("func_name"),
),
(
"direct fallback",
None,
Some("direct_name"),
Some("direct_name"),
),
("both none", None, None, None),
(
"empty function name",
Some(""),
Some("direct_name"),
Some("direct_name"),
),
(
"empty direct name",
Some("func_name"),
Some(""),
Some("func_name"),
),
("both empty", Some(""), Some(""), None),
];
for (name, fn_name, direct_name, expected) in cases {
assert_eq!(
resolve_tool_call_name(*fn_name, *direct_name),
expected.map(String::from),
"{name}",
);
}
}
#[test]
#[expect(clippy::type_complexity)]
fn resolve_tool_call_arguments_cases() {
let cases: &[(&str, Option<&str>, Option<&str>, Option<&str>, Option<&str>)] = &[
(
"function wins",
Some(r#"{"key":"func_val"}"#),
Some(r#"{"key":"direct_val"}"#),
None,
Some(r#"{"key":"func_val"}"#),
),
(
"direct fallback",
None,
Some(r#"{"key":"val"}"#),
None,
Some(r#"{"key":"val"}"#),
),
("both none", None, None, None, None),
(
"empty function args",
Some(""),
Some(r#"{"key":"val"}"#),
None,
Some(r#"{"key":"val"}"#),
),
(
"empty direct args",
Some(r#"{"key":"val"}"#),
Some(""),
None,
Some(r#"{"key":"val"}"#),
),
("both empty", Some(""), Some(""), None, None),
(
"parameters fallback",
None,
None,
Some(r#"{"command":"pwd"}"#),
Some(r#"{"command":"pwd"}"#),
),
(
"string fields empty with parameters",
Some(""),
Some(""),
Some(r#"{"command":"ls"}"#),
Some(r#"{"command":"ls"}"#),
),
(
"function wins over parameters",
Some(r#"{"key":"val"}"#),
None,
Some(r#"{"query":"test"}"#),
Some(r#"{"key":"val"}"#),
),
];
for (name, fn_args, direct_args, params_json, expected) in cases {
let params = params_json.map(|j| serde_json::from_str(j).expect(name));
assert_eq!(
resolve_tool_call_arguments(*fn_args, *direct_args, params.as_ref()),
expected.map(String::from),
"{name}",
);
}
}
#[test]
fn parse_native_response_preserves_tool_call_id() {
let message = ResponseMessage {
content: None,
tool_calls: Some(vec![ApiToolCall {
id: Some("call_123".to_string()),
kind: Some("function".to_string()),
function: Some(ApiToolCallFunction {
name: Some("shell".to_string()),
arguments: Some(r#"{"command":"pwd"}"#.to_string()),
}),
name: None,
arguments: None,
parameters: None,
}]),
reasoning_content: None,
reasoning: None,
reasoning_details: None,
};
let parsed =
OpenAiCompatibleProvider::parse_native_response(message, None, None, None, None);
assert_eq!(parsed.tool_calls.len(), 1);
assert_eq!(parsed.tool_calls[0].id, "call_123");
assert_eq!(parsed.tool_calls[0].name, "shell");
}
#[test]
fn convert_messages_for_native_maps_tool_result_payload() {
let input = vec![ChatMessage::tool_result("call_abc", "done")];
let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input, true);
assert_eq!(converted[0].tool_call_id.as_deref(), Some("call_abc"));
assert!(matches!(
converted[0].content.as_ref(),
Some(MessageContent::Text(value)) if value == "done"
));
}
#[test]
fn convert_messages_for_native_keeps_user_image_markers_as_text_when_disabled() {
let input = vec![ChatMessage::user(
"System primer [IMAGE:data:image/png;base64,abcd] user turn",
)];
let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input, false);
assert_eq!(converted.len(), 1);
assert_eq!(converted[0].role, "user");
assert!(matches!(
converted[0].content.as_ref(),
Some(MessageContent::Text(value))
if value == "System primer [IMAGE:data:image/png;base64,abcd] user turn"
));
}
#[test]
fn effective_content_optional_never_promotes_reasoning() {
let json = r#"{"choices":[{"message":{"content":"","reasoning_content":"Thinking output here"}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.choices[0]
.message
.effective_content_optional()
.unwrap_or_default(),
""
);
let json =
r#"{"choices":[{"message":{"content":null,"reasoning_content":"Fallback text"}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.choices[0]
.message
.effective_content_optional()
.unwrap_or_default(),
""
);
let json = r#"{"choices":[{"message":{"reasoning_content":"Only thinking"}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.choices[0]
.message
.effective_content_optional()
.unwrap_or_default(),
""
);
let json = r#"{"choices":[{"message":{"content":"Normal response","reasoning_content":"Should be ignored"}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.choices[0]
.message
.effective_content_optional()
.unwrap_or_default(),
"Normal response"
);
let json = r#"{"choices":[{"message":{"content":"<think>secret</think>","reasoning_content":"Fallback text"}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.choices[0]
.message
.effective_content_optional()
.unwrap_or_default(),
""
);
let json =
r#"{"choices":[{"message":{"content":"<think>secret</think>\nVisible answer"}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.choices[0]
.message
.effective_content_optional()
.unwrap_or_default(),
"Visible answer"
);
let json = r#"{"choices":[{"message":{}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.choices[0]
.message
.effective_content_optional()
.unwrap_or_default(),
""
);
let json = r#"{"choices":[{"message":{"content":"Hello from Venice!"}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert!(resp.choices[0].message.reasoning_content.is_none());
assert_eq!(
resp.choices[0]
.message
.effective_content_optional()
.unwrap_or_default(),
"Hello from Venice!"
);
}
#[tokio::test]
async fn warmup_without_key_attempts_connection() {
let provider = OpenAiCompatibleProvider::new("test", "http://127.0.0.1:1", None);
let result = provider.warmup().await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
!err_msg.contains("API key not set"),
"should not get credential error, got: {err_msg}"
);
}
#[test]
fn parse_image_markers_extracts_multiple_markers() {
let input = "Check this [IMAGE:/tmp/a.png] and this [IMAGE:https://example.com/b.jpg]";
let (cleaned, refs) = parse_image_markers(input);
assert_eq!(cleaned, "Check this and this");
assert_eq!(refs.len(), 2);
assert_eq!(refs[0], "/tmp/a.png");
assert_eq!(refs[1], "https://example.com/b.jpg");
}
#[test]
fn parse_image_markers_keeps_invalid_empty_marker() {
let input = "hello [IMAGE:] world";
let (cleaned, refs) = parse_image_markers(input);
assert_eq!(cleaned, "hello [IMAGE:] world");
assert!(refs.is_empty());
}
#[test]
fn parse_image_markers_strips_markers_leaving_caption() {
let input = "[IMAGE:/tmp/photo.jpg]\n\nDescribe this screenshot";
let (cleaned, refs) = parse_image_markers(input);
assert_eq!(cleaned, "Describe this screenshot");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0], "/tmp/photo.jpg");
}
#[test]
fn parse_image_markers_image_only_message_becomes_empty() {
let input = "[IMAGE:/tmp/photo.jpg]";
let (cleaned, refs) = parse_image_markers(input);
assert!(
cleaned.is_empty(),
"expected empty string, got: {cleaned:?}"
);
assert_eq!(refs.len(), 1);
}
#[test]
fn parse_image_markers_preserves_audio_and_video_markers() {
let input =
"[AUDIO:/tmp/sound.mp3] Listen to this [VIDEO:/tmp/clip.mp4] and [IMAGE:/tmp/img.png]";
let (cleaned, refs) = parse_image_markers(input);
assert_eq!(
cleaned,
"[AUDIO:/tmp/sound.mp3] Listen to this [VIDEO:/tmp/clip.mp4] and"
);
assert_eq!(refs, vec!["/tmp/img.png"]);
}
#[test]
fn to_message_content_converts_image_markers_to_openai_parts() {
let content = "Describe this\n\n[IMAGE:data:image/png;base64,abcd]";
let value =
serde_json::to_value(to_message_content(ChatRole::User, content, true)).unwrap();
let parts = value
.as_array()
.expect("multimodal content should be an array");
assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["type"], "text");
assert_eq!(parts[0]["text"], "Describe this");
assert_eq!(parts[1]["type"], "image_url");
assert_eq!(parts[1]["image_url"]["url"], "data:image/png;base64,abcd");
}
#[test]
fn to_message_content_keeps_markers_as_text_when_user_image_parts_disabled() {
let content = "Policy [IMAGE:data:image/png;base64,abcd]";
let value =
serde_json::to_value(to_message_content(ChatRole::User, content, false)).unwrap();
assert_eq!(value, serde_json::json!(content));
}
#[test]
fn to_message_content_keeps_plain_text_for_non_user_roles() {
let value = serde_json::to_value(to_message_content(
ChatRole::System,
"You are a helpful assistant.",
true,
))
.unwrap();
assert_eq!(value, serde_json::json!("You are a helpful assistant."));
}
#[test]
fn request_serializes_with_tools() {
let tools = vec![serde_json::json!({
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather for a location",
"parameters": {
"type": "object",
"properties": {
"location": {"type": "string"}
}
}
}
})];
let req = ChatCompletionRequest {
model: "test-model".to_string(),
messages: vec![NativeMessage::user("What is the weather?")],
max_tokens: Some(32000),
tools: Some(tools),
tool_choice: Some("auto".to_string()),
extra: serde_json::Map::new(),
};
let json = serde_json::to_string(&req).unwrap();
assert!(json.contains("\"tools\""));
assert!(json.contains("get_weather"));
assert!(json.contains("\"tool_choice\":\"auto\""));
}
#[test]
fn response_with_tool_calls_deserializes() {
let json = r#"{
"choices": [{
"message": {
"content": null,
"tool_calls": [{
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"London\"}"
}
}]
}
}]
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
let msg = &resp.choices[0].message;
assert!(msg.content.is_none());
let tool_calls = msg.tool_calls.as_ref().unwrap();
assert_eq!(tool_calls.len(), 1);
assert_eq!(
tool_calls[0].function.as_ref().unwrap().name.as_deref(),
Some("get_weather")
);
assert_eq!(
tool_calls[0]
.function
.as_ref()
.unwrap()
.arguments
.as_deref(),
Some("{\"location\":\"London\"}")
);
}
#[test]
fn response_with_multiple_tool_calls() {
let json = r#"{
"choices": [{
"message": {
"content": "I'll check both.",
"tool_calls": [
{
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"location\":\"London\"}"
}
},
{
"type": "function",
"function": {
"name": "get_time",
"arguments": "{\"timezone\":\"UTC\"}"
}
}
]
}
}]
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
let msg = &resp.choices[0].message;
assert_eq!(msg.content.as_deref(), Some("I'll check both."));
let tool_calls = msg.tool_calls.as_ref().unwrap();
assert_eq!(tool_calls.len(), 2);
assert_eq!(
tool_calls[0].function.as_ref().unwrap().name.as_deref(),
Some("get_weather")
);
assert_eq!(
tool_calls[1].function.as_ref().unwrap().name.as_deref(),
Some("get_time")
);
}
#[test]
fn response_with_no_tool_calls_has_empty_vec() {
let json = r#"{"choices":[{"message":{"content":"Just text, no tools."}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
let msg = &resp.choices[0].message;
assert_eq!(msg.content.as_deref(), Some("Just text, no tools."));
assert!(msg.tool_calls.is_none());
}
#[test]
fn api_response_parses_usage() {
let json = r#"{
"choices": [{"message": {"content": "Hello"}}],
"usage": {"prompt_tokens": 150, "completion_tokens": 60}
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
let usage = resp.usage.unwrap();
assert_eq!(usage.prompt_tokens, Some(150));
assert_eq!(usage.completion_tokens, Some(60));
}
#[test]
fn cache_tokens_normalized_for_both_provider_shapes() {
let (cached, miss) = normalize_cache_tokens(Some(90), None, None, Some(150));
assert_eq!(cached, Some(90));
assert_eq!(miss, Some(60));
let (cached, miss) = normalize_cache_tokens(None, Some(90), Some(60), Some(150));
assert_eq!(cached, Some(90));
assert_eq!(miss, Some(60));
let (cached, miss) = normalize_cache_tokens(Some(90), None, None, None);
assert_eq!(cached, Some(90));
assert_eq!(miss, None);
}
#[test]
fn api_response_parses_cached_tokens() {
let json = r#"{
"choices": [{"message": {"content": "Hello"}}],
"usage": {
"prompt_tokens": 150,
"completion_tokens": 60,
"prompt_tokens_details": {"cached_tokens": 90}
}
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
let usage = resp.usage.unwrap();
assert_eq!(usage.prompt_tokens_details.unwrap().cached_tokens, Some(90));
}
#[test]
fn api_response_parses_without_usage() {
let json = r#"{"choices": [{"message": {"content": "Hello"}}]}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert!(resp.usage.is_none());
}
#[test]
fn api_response_parses_cost_fingerprint_and_upstream_provider() {
let json = r#"{
"choices": [{"message": {"content": "Hello"}}],
"provider": "DeepSeek",
"system_fingerprint": "fp_44709d6fcb",
"usage": {
"prompt_tokens": 150,
"completion_tokens": 60,
"cost": 0.0012,
"cost_details": {
"upstream_inference_prompt_cost": 0.0008,
"upstream_inference_completions_cost": 0.0004
}
}
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.system_fingerprint.as_deref(), Some("fp_44709d6fcb"));
assert_eq!(resp.provider.as_deref(), Some("DeepSeek"));
let usage = resp.usage.unwrap();
assert!((usage.cost.expect("cost") - 0.0012).abs() < 1e-12);
assert_eq!(
usage.cost_details.as_ref().map(serde_json::Value::to_string),
Some(
r#"{"upstream_inference_completions_cost":0.0004,"upstream_inference_prompt_cost":0.0008}"#
.to_string()
)
);
assert_eq!(
usage
.cost_details
.expect("cost_details")
.pointer("/upstream_inference_prompt_cost")
.and_then(serde_json::Value::as_f64),
Some(0.0008)
);
}
#[test]
fn upstream_provider_present_on_cache_hit() {
let json = r#"{
"choices": [{"message": {"content": "cached answer"}}],
"provider": "Friendli",
"system_fingerprint": "fp_44709d6fcb",
"usage": {
"prompt_tokens": 8,
"completion_tokens": 4,
"prompt_tokens_details": {"cached_tokens": 8},
"cost": 0.0001
}
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.provider.as_deref(), Some("Friendli"));
let resp: ApiChatResponse =
serde_json::from_str(r#"{"choices":[{"message":{"content":"x"}}]}"#).unwrap();
assert!(resp.provider.is_none());
}
#[test]
fn wrong_typed_telemetry_fields_do_not_break_envelope_parse() {
let json = r#"{
"choices": [{"message": {"content": "Hello"}}],
"provider": 42,
"system_fingerprint": 42,
"usage": {"cost": "not-a-number", "cost_details": [1, 2]}
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
assert!(resp.system_fingerprint.is_none());
assert!(resp.provider.is_none());
let usage = resp.usage.unwrap();
assert!(usage.cost.is_none());
assert!(usage.cost_details.is_some(), "Value accepts any JSON");
let resp: ApiChatResponse =
serde_json::from_str(r#"{"choices":[{"message":{"content":"x"}}]}"#).unwrap();
assert!(resp.system_fingerprint.is_none());
assert!(resp.provider.is_none());
assert!(resp.usage.is_none());
}
#[test]
fn pathological_cost_details_number_does_not_break_envelope_parse() {
let json = r#"{
"choices": [{"message": {"content": "Hello"}}],
"usage": {"cost_details": {"upstream_inference_prompt_cost": 1e999}}
}"#;
let resp: ApiChatResponse = serde_json::from_str(json).unwrap();
let usage = resp.usage.unwrap();
assert!(usage.cost_details.is_none());
assert!(usage.cost.is_none());
}
#[test]
fn parse_native_response_captures_reasoning_content() {
let message = ResponseMessage {
content: Some("answer".to_string()),
reasoning_content: Some("thinking step".to_string()),
reasoning: None,
reasoning_details: None,
tool_calls: Some(vec![ApiToolCall {
id: Some("call_1".to_string()),
kind: Some("function".to_string()),
function: Some(ApiToolCallFunction {
name: Some("shell".to_string()),
arguments: Some(r#"{"cmd":"ls"}"#.to_string()),
}),
name: None,
arguments: None,
parameters: None,
}]),
};
let parsed =
OpenAiCompatibleProvider::parse_native_response(message, None, None, None, None);
let rc = parsed
.reasoning
.as_ref()
.and_then(|r| r.reasoning_content.clone());
assert_eq!(rc.as_deref(), Some("thinking step"));
assert_eq!(parsed.text.as_deref(), Some("answer"));
assert_eq!(parsed.tool_calls.len(), 1);
}
#[test]
fn parse_native_response_none_reasoning_content_for_normal_model() {
let message = ResponseMessage {
content: Some("hello".to_string()),
reasoning_content: None,
reasoning: None,
reasoning_details: None,
tool_calls: None,
};
let parsed =
OpenAiCompatibleProvider::parse_native_response(message, None, None, None, None);
assert!(parsed.reasoning.is_none());
assert_eq!(parsed.text.as_deref(), Some("hello"));
}
#[test]
fn convert_messages_for_native_round_trips_reasoning_content() {
let history_json = serde_json::json!({
"content": "I will check",
"tool_calls": [{
"id": "tc_1",
"name": "shell",
"arguments": "{\"cmd\":\"ls\"}"
}],
"reasoning_content": "Let me think about this..."
});
let messages = vec![ChatMessage::assistant(history_json.to_string())];
let native = OpenAiCompatibleProvider::convert_messages_for_native(&messages, true);
assert_eq!(native.len(), 1);
assert_eq!(native[0].role, "assistant");
assert_eq!(
native[0].reasoning_content.as_deref(),
Some("Let me think about this...")
);
assert!(native[0].tool_calls.is_some());
}
#[test]
fn convert_messages_for_native_no_reasoning_content_when_absent() {
let history_json = serde_json::json!({
"content": "I will check",
"tool_calls": [{
"id": "tc_1",
"name": "shell",
"arguments": "{\"cmd\":\"ls\"}"
}]
});
let messages = vec![ChatMessage::assistant(history_json.to_string())];
let native = OpenAiCompatibleProvider::convert_messages_for_native(&messages, true);
assert_eq!(native.len(), 1);
assert!(native[0].reasoning_content.is_none());
}
#[test]
fn convert_messages_for_native_synthesizes_reasoning_content_from_details_for_tool_calls() {
let details = serde_json::json!([
{"type": "reasoning.text", "text": "from details", "format": "x", "index": 0}
]);
let history_json = serde_json::json!({
"content": "I will check",
"tool_calls": [{
"id": "tc_1",
"name": "shell",
"arguments": "{\"cmd\":\"ls\"}"
}],
"reasoning_details": details.clone(),
});
let messages = vec![ChatMessage::assistant(history_json.to_string())];
let native = OpenAiCompatibleProvider::convert_messages_for_native(&messages, true);
assert_eq!(native.len(), 1);
assert_eq!(native[0].reasoning_content.as_deref(), Some("from details"));
assert_eq!(native[0].reasoning_details.as_ref(), Some(&details));
}
}