use super::client::{OpenRouterExt, Usage};
use crate::message::{self, DocumentMediaType, DocumentSourceKind, MimeType};
use crate::telemetry::ProviderResponseExt;
use crate::{
completion::{self, CompletionError, CompletionRequest},
json_utils,
providers::internal::openai_chat_completions_compatible::map_openai_finish_reason,
providers::openai,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub const QWEN_QWQ_32B: &str = "qwen/qwq-32b";
pub const CLAUDE_3_7_SONNET: &str = "anthropic/claude-3.7-sonnet";
pub const PERPLEXITY_SONAR_PRO: &str = "perplexity/sonar-pro";
pub const GEMINI_FLASH_2_0: &str = "google/gemini-2.0-flash-001";
pub(crate) const PROVIDER_NAME: &str = "openrouter";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum DataCollection {
#[default]
Allow,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Quantization {
#[serde(rename = "int4")]
Int4,
#[serde(rename = "int8")]
Int8,
#[serde(rename = "fp16")]
Fp16,
#[serde(rename = "bf16")]
Bf16,
#[serde(rename = "fp32")]
Fp32,
#[serde(rename = "fp8")]
Fp8,
#[serde(rename = "unknown")]
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderSortStrategy {
Price,
Throughput,
Latency,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SortPartition {
Model,
None,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderSortConfig {
pub by: ProviderSortStrategy,
#[serde(skip_serializing_if = "Option::is_none")]
pub partition: Option<SortPartition>,
}
impl ProviderSortConfig {
pub fn new(by: ProviderSortStrategy) -> Self {
Self {
by,
partition: None,
}
}
pub fn partition(mut self, partition: SortPartition) -> Self {
self.partition = Some(partition);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ProviderSort {
Simple(ProviderSortStrategy),
Complex(ProviderSortConfig),
}
impl From<ProviderSortStrategy> for ProviderSort {
fn from(strategy: ProviderSortStrategy) -> Self {
ProviderSort::Simple(strategy)
}
}
impl From<ProviderSortConfig> for ProviderSort {
fn from(config: ProviderSortConfig) -> Self {
ProviderSort::Complex(config)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ThroughputThreshold {
Simple(f64),
Percentile(PercentileThresholds),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum LatencyThreshold {
Simple(f64),
Percentile(PercentileThresholds),
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct PercentileThresholds {
#[serde(skip_serializing_if = "Option::is_none")]
pub p50: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub p75: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub p90: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub p99: Option<f64>,
}
impl PercentileThresholds {
pub fn new() -> Self {
Self::default()
}
pub fn p50(mut self, value: f64) -> Self {
self.p50 = Some(value);
self
}
pub fn p75(mut self, value: f64) -> Self {
self.p75 = Some(value);
self
}
pub fn p90(mut self, value: f64) -> Self {
self.p90 = Some(value);
self
}
pub fn p99(mut self, value: f64) -> Self {
self.p99 = Some(value);
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct MaxPrice {
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completion: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<f64>,
}
impl MaxPrice {
pub fn new() -> Self {
Self::default()
}
pub fn prompt(mut self, price: f64) -> Self {
self.prompt = Some(price);
self
}
pub fn completion(mut self, price: f64) -> Self {
self.completion = Some(price);
self
}
pub fn request(mut self, price: f64) -> Self {
self.request = Some(price);
self
}
pub fn image(mut self, price: f64) -> Self {
self.image = Some(price);
self
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ProviderPreferences {
#[serde(skip_serializing_if = "Option::is_none")]
pub order: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub only: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_fallbacks: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub require_parameters: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data_collection: Option<DataCollection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zdr: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sort: Option<ProviderSort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_min_throughput: Option<ThroughputThreshold>,
#[serde(skip_serializing_if = "Option::is_none")]
pub preferred_max_latency: Option<LatencyThreshold>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_price: Option<MaxPrice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quantizations: Option<Vec<Quantization>>,
}
impl ProviderPreferences {
pub fn new() -> Self {
Self::default()
}
pub fn order(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.order = Some(providers.into_iter().map(|p| p.into()).collect());
self
}
pub fn only(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.only = Some(providers.into_iter().map(|p| p.into()).collect());
self
}
pub fn ignore(mut self, providers: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.ignore = Some(providers.into_iter().map(|p| p.into()).collect());
self
}
pub fn allow_fallbacks(mut self, allow: bool) -> Self {
self.allow_fallbacks = Some(allow);
self
}
pub fn require_parameters(mut self, require: bool) -> Self {
self.require_parameters = Some(require);
self
}
pub fn data_collection(mut self, policy: DataCollection) -> Self {
self.data_collection = Some(policy);
self
}
pub fn zdr(mut self, enable: bool) -> Self {
self.zdr = Some(enable);
self
}
pub fn sort(mut self, sort: impl Into<ProviderSort>) -> Self {
self.sort = Some(sort.into());
self
}
pub fn preferred_min_throughput(mut self, threshold: ThroughputThreshold) -> Self {
self.preferred_min_throughput = Some(threshold);
self
}
pub fn preferred_max_latency(mut self, threshold: LatencyThreshold) -> Self {
self.preferred_max_latency = Some(threshold);
self
}
pub fn max_price(mut self, price: MaxPrice) -> Self {
self.max_price = Some(price);
self
}
pub fn quantizations(mut self, quantizations: impl IntoIterator<Item = Quantization>) -> Self {
self.quantizations = Some(quantizations.into_iter().collect());
self
}
pub fn zero_data_retention(self) -> Self {
self.zdr(true)
}
pub fn fastest(self) -> Self {
self.sort(ProviderSortStrategy::Throughput)
}
pub fn cheapest(self) -> Self {
self.sort(ProviderSortStrategy::Price)
}
pub fn lowest_latency(self) -> Self {
self.sort(ProviderSortStrategy::Latency)
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"provider": self
})
}
}
fn deserialize_openrouter_choices_dropping_incomplete_tool_calls<'de, D>(
deserializer: D,
) -> Result<Vec<Choice>, D::Error>
where
D: serde::Deserializer<'de>,
{
crate::providers::internal::openai_chat_completions_compatible::deserialize_choices_dropping_incomplete_tool_calls_when(
deserializer,
|choice| {
let normalized = choice
.get("finish_reason")
.and_then(serde_json::Value::as_str)
.filter(|reason| !reason.is_empty());
if let Some(reason) = normalized {
return matches!(map_openai_finish_reason(reason), completion::FinishReason::Length);
}
choice
.get("native_finish_reason")
.and_then(serde_json::Value::as_str)
.filter(|reason| !reason.is_empty())
.is_some_and(|reason| {
matches!(map_native_finish_reason(reason), completion::FinishReason::Length)
})
},
)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CompletionResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
#[serde(deserialize_with = "deserialize_openrouter_choices_dropping_incomplete_tool_calls")]
pub choices: Vec<Choice>,
pub system_fingerprint: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
pub usage: Option<Usage>,
}
pub(crate) fn map_finish_reason(choice: &Choice) -> Option<completion::FinishReason> {
if let Some(reason) = choice
.finish_reason
.as_deref()
.filter(|reason| !reason.is_empty())
{
return Some(map_openai_finish_reason(reason));
}
choice
.native_finish_reason
.as_deref()
.filter(|reason| !reason.is_empty())
.map(map_native_finish_reason)
}
pub(crate) fn map_native_finish_reason(reason: &str) -> completion::FinishReason {
match reason.to_ascii_lowercase().as_str() {
"stop" | "end_turn" | "stop_sequence" | "complete" | "completed" => {
completion::FinishReason::Stop
}
"length" | "max_tokens" | "max_output_tokens" | "model_length" => {
completion::FinishReason::Length
}
"tool_calls" | "function_call" | "tool_use" => completion::FinishReason::ToolCalls,
"content_filter" | "safety" | "blocklist" | "prohibited_content" | "spii" => {
completion::FinishReason::ContentFilter
}
_ => completion::FinishReason::Other(reason.to_owned()),
}
}
impl crate::completion::NormalizeCompletionResponse for CompletionResponse {
fn normalize(self, provider: &str) -> Result<completion::CompletionResponse, CompletionError> {
let response = self;
let choice = response.choices.first().ok_or_else(|| {
CompletionError::ResponseError("Response contained no choices".to_owned())
})?;
let finish_reason = map_finish_reason(choice);
let content = match &choice.message {
Message::Assistant {
content: message_content,
tool_calls,
reasoning,
reasoning_details,
images,
refusal,
..
} => {
let refusal_fallback = openai::completion::assistant_refusal_fallback(
message_content,
refusal.as_deref(),
);
let mut normalized_content = Vec::new();
let mut grouped_reasoning: HashMap<
Option<String>,
Vec<(usize, usize, message::ReasoningContent)>,
> = HashMap::new();
let mut reasoning_order: Vec<Option<String>> = Vec::new();
for (position, detail) in reasoning_details.iter().enumerate() {
let (reasoning_id, sort_index, parsed_content) = match detail {
ReasoningDetails::Summary {
id, index, summary, ..
} => (
id.clone(),
*index,
Some(message::ReasoningContent::Summary(summary.clone())),
),
ReasoningDetails::Encrypted {
id, index, data, ..
} => (
id.clone(),
*index,
Some(message::ReasoningContent::Encrypted(data.clone())),
),
ReasoningDetails::Text {
id,
index,
text,
signature,
..
} => (
id.clone(),
*index,
text.as_ref().map(|text| message::ReasoningContent::Text {
text: text.clone(),
signature: signature.clone(),
}),
),
};
let Some(parsed_content) = parsed_content else {
continue;
};
let sort_index = sort_index.unwrap_or(position);
let entry = grouped_reasoning.entry(reasoning_id.clone());
if matches!(entry, std::collections::hash_map::Entry::Vacant(_)) {
reasoning_order.push(reasoning_id);
}
entry
.or_default()
.push((sort_index, position, parsed_content));
}
if grouped_reasoning.is_empty() {
if let Some(reasoning) = reasoning {
normalized_content.push(completion::AssistantContent::reasoning(reasoning));
}
} else {
for reasoning_id in reasoning_order {
let Some(mut blocks) = grouped_reasoning.remove(&reasoning_id) else {
continue;
};
blocks.sort_by_key(|(index, position, _)| (*index, *position));
normalized_content.push(completion::AssistantContent::Reasoning(
message::Reasoning {
id: reasoning_id,
content: blocks
.into_iter()
.map(|(_, _, content)| content)
.collect::<Vec<_>>(),
},
));
}
}
normalized_content.extend(message_content.iter().map(|part| match part {
openai::AssistantContent::Text { text, .. } => {
completion::AssistantContent::text(text)
}
openai::AssistantContent::Refusal { refusal } => {
completion::AssistantContent::text(refusal)
}
}));
if let Some(refusal) = refusal_fallback {
normalized_content.push(completion::AssistantContent::text(refusal));
}
normalized_content.extend(tool_calls.iter().map(|call| {
completion::AssistantContent::tool_call(
&call.id,
&call.function.name,
call.function.arguments.clone(),
)
}));
normalized_content.extend(images.iter().map(response_image_to_assistant_content));
Ok(normalized_content)
}
_ => Err(CompletionError::ResponseError(
"Response did not contain a valid message or tool call".into(),
)),
}?;
let choice = match &finish_reason {
Some(reason) if reason.truncated_output() => content,
_ => crate::message::require_non_empty_response(content)?,
};
let usage = response
.usage
.as_ref()
.map(completion::Usage::from)
.unwrap_or_default();
Ok(
completion::CompletionResponse::new(choice, usage, provider)
.with_response_id(response.id)
.with_model(response.model)
.with_optional_finish_reason(finish_reason),
)
}
}
impl ProviderResponseExt for CompletionResponse {
type Usage = Usage;
fn get_response_id(&self) -> Option<String> {
Some(self.id.clone())
}
fn get_response_model_name(&self) -> Option<String> {
Some(self.model.clone())
}
fn get_text_response(&self) -> Option<String> {
let response = self
.choices
.iter()
.filter_map(|choice| {
openai::completion::assistant_message_text_response(&choice.message)
})
.collect::<Vec<_>>()
.join("\n");
(!response.is_empty()).then_some(response)
}
fn get_usage(&self) -> Option<Self::Usage> {
self.usage.clone()
}
}
pub use crate::providers::openai::completion::{
FileData, ImageUrl, Message, ReasoningDetails, ResponseImage, UserContent, VideoUrl,
};
const OPENROUTER_RESPONSE_ONLY_KEY: &str = "response_only";
const OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY: &str = "source";
const OPENROUTER_ASSISTANT_IMAGES_SOURCE: &str = "assistant.images";
fn parse_data_uri(url: &str) -> Option<(&str, &str)> {
url.strip_prefix("data:")?.split_once(";base64,")
}
fn openrouter_response_image_params() -> Option<message::AdditionalParams> {
message::AdditionalParams::from_entries([(
"openrouter",
serde_json::json!({
OPENROUTER_RESPONSE_ONLY_KEY: true,
OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY: OPENROUTER_ASSISTANT_IMAGES_SOURCE,
}),
)])
}
fn response_image_to_assistant_content(image: &ResponseImage) -> completion::AssistantContent {
let url = &image.image_url.url;
if let Some((mime, b64)) = parse_data_uri(url) {
completion::AssistantContent::Image(message::Image {
data: message::DocumentSourceKind::Base64(b64.to_string()),
media_type: message::ImageMediaType::from_mime_type(mime),
detail: None,
additional_params: openrouter_response_image_params(),
})
} else {
completion::AssistantContent::Image(message::Image {
data: message::DocumentSourceKind::Url(url.clone()),
media_type: None,
detail: None,
additional_params: openrouter_response_image_params(),
})
}
}
fn is_openrouter_response_image(image: &message::Image) -> bool {
image
.additional_params
.as_ref()
.and_then(|params| params.wire_extras("openrouter"))
.is_some_and(|params| {
params
.get(OPENROUTER_RESPONSE_ONLY_KEY)
.and_then(|value| value.as_bool())
.unwrap_or(false)
&& params
.get(OPENROUTER_RESPONSE_IMAGE_SOURCE_KEY)
.and_then(|value| value.as_str())
== Some(OPENROUTER_ASSISTANT_IMAGES_SOURCE)
})
}
fn user_content_to_openai(
value: message::UserContent,
) -> Result<UserContent, message::MessageError> {
match value {
message::UserContent::Image(message::Image {
data,
detail,
media_type,
..
}) => {
let url = match data {
DocumentSourceKind::Url(url) => url,
DocumentSourceKind::Base64(data) => {
let mime = media_type
.ok_or_else(|| {
message::MessageError::ConversionError(
"Image media type required for base64 encoding".into(),
)
})?
.to_mime_type();
format!("data:{mime};base64,{data}")
}
DocumentSourceKind::Raw(_) => {
return Err(message::MessageError::ConversionError(
"Raw bytes not supported, encode as base64 first".into(),
));
}
DocumentSourceKind::FileId(_) => {
return Err(message::MessageError::ConversionError(
"File IDs are not supported for images".into(),
));
}
DocumentSourceKind::String(_) => {
return Err(message::MessageError::ConversionError(
"String source not supported for images".into(),
));
}
DocumentSourceKind::Unknown => {
return Err(message::MessageError::ConversionError(
"Image has no data".into(),
));
}
};
Ok(UserContent::Image {
image_url: ImageUrl { url, detail },
})
}
message::UserContent::Document(message::Document {
data, media_type, ..
}) => match data {
DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
"Provider file IDs are not supported for OpenRouter document inputs".into(),
)),
DocumentSourceKind::Url(url) => Ok(UserContent::File {
file: FileData {
file_data: Some(url),
file_id: None,
filename: document_filename(media_type.as_ref()),
},
}),
DocumentSourceKind::Base64(data) => {
let mime = media_type
.as_ref()
.map(|m| m.to_mime_type())
.unwrap_or("application/pdf");
let data_uri = format!("data:{mime};base64,{data}");
Ok(UserContent::File {
file: FileData {
file_data: Some(data_uri),
file_id: None,
filename: document_filename(media_type.as_ref()),
},
})
}
DocumentSourceKind::String(text) => Ok(UserContent::Text { text }),
DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
"Raw bytes not supported for documents, encode as base64 first".into(),
)),
DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
"Document has no data".into(),
)),
},
message::UserContent::Audio(message::Audio {
data, media_type, ..
}) => match data {
DocumentSourceKind::Base64(data) => {
let format = media_type.ok_or_else(|| {
message::MessageError::ConversionError(
"Audio media type required for base64 encoding".into(),
)
})?;
Ok(UserContent::Audio {
input_audio: openai::InputAudio { data, format },
})
}
DocumentSourceKind::Url(_) => Err(message::MessageError::ConversionError(
"OpenRouter does not support audio URLs, encode as base64 first".into(),
)),
DocumentSourceKind::Raw(_) => Err(message::MessageError::ConversionError(
"Raw bytes not supported for audio, encode as base64 first".into(),
)),
DocumentSourceKind::FileId(_) => Err(message::MessageError::ConversionError(
"File IDs are not supported for audio".into(),
)),
DocumentSourceKind::String(_) => Err(message::MessageError::ConversionError(
"String source not supported for audio".into(),
)),
DocumentSourceKind::Unknown => Err(message::MessageError::ConversionError(
"Audio has no data".into(),
)),
},
message::UserContent::ToolResult(_) => Err(message::MessageError::ConversionError(
"Tool results should be handled as separate messages".into(),
)),
value => UserContent::try_from(value),
}
}
fn document_filename(media_type: Option<&DocumentMediaType>) -> Option<String> {
media_type.map(|mt| {
match mt {
DocumentMediaType::PDF => "document.pdf",
DocumentMediaType::TXT => "document.txt",
DocumentMediaType::HTML => "document.html",
DocumentMediaType::MARKDOWN => "document.md",
DocumentMediaType::CSV => "document.csv",
DocumentMediaType::XML => "document.xml",
_ => "document",
}
.to_string()
})
}
fn user_contents_to_messages(
value: Vec<message::UserContent>,
) -> Result<Vec<Message>, message::MessageError> {
fn flush_user_content(messages: &mut Vec<Message>, pending: &mut Vec<UserContent>) {
if pending.is_empty() {
return;
}
messages.push(Message::User {
content: std::mem::take(pending),
name: None,
});
}
let mut messages = Vec::new();
let mut pending = Vec::new();
for content in value {
match content {
message::UserContent::ToolResult(tool_result) => {
flush_user_content(&mut messages, &mut pending);
let tool_call_id = tool_result.wire_call_id().to_owned();
let content = tool_result
.content
.into_iter()
.map(|content| match content {
message::ToolResultContent::Text(message::Text { text, .. }) => Ok(text),
message::ToolResultContent::Json { value } => Ok(value.to_string()),
message::ToolResultContent::Image(_) => {
Err(message::MessageError::ConversionError(
"OpenRouter does not support images in tool results".into(),
))
}
})
.collect::<Result<Vec<_>, _>>()?
.join("\n");
messages.push(Message::ToolResult {
tool_call_id,
content: openai::completion::ToolResultContentValue::String(content),
});
}
content => pending.push(user_content_to_openai(content)?),
}
}
flush_user_content(&mut messages, &mut pending);
Ok(messages)
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Choice {
pub index: usize,
pub native_finish_reason: Option<String>,
pub message: Message,
pub finish_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logprobs: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize, PartialEq, Clone)]
#[serde(untagged)]
enum ToolCallAdditionalParams {
ReasoningDetails(ReasoningDetails),
Minimal {
id: Option<String>,
format: Option<String>,
},
}
fn assistant_contents_to_messages(
value: Vec<message::AssistantContent>,
) -> Result<Vec<Message>, message::MessageError> {
let mut text_content = Vec::new();
let mut tool_calls = Vec::new();
let mut reasoning = None;
let mut reasoning_details = Vec::new();
for content in value.into_iter() {
match content {
message::AssistantContent::Text(text) => text_content.push(text),
message::AssistantContent::ToolCall(tool_call) => {
if let Some(additional_params) = &tool_call.additional_params
&& let Ok(additional_params) = serde_json::from_value::<ToolCallAdditionalParams>(
additional_params.clone(),
)
{
match additional_params {
ToolCallAdditionalParams::ReasoningDetails(full) => {
reasoning_details.push(full);
}
ToolCallAdditionalParams::Minimal { id, format } => {
let id = id
.or_else(|| {
tool_call
.provider
.as_ref()
.map(|provider| provider.call_id.clone())
})
.unwrap_or_else(|| tool_call.id.as_str().to_owned());
if let Some(signature) = &tool_call.signature {
reasoning_details.push(ReasoningDetails::Encrypted {
id: Some(id),
format,
index: None,
data: signature.clone(),
})
}
}
}
} else if let Some(signature) = &tool_call.signature {
reasoning_details.push(ReasoningDetails::Encrypted {
id: Some(
tool_call
.provider
.as_ref()
.map(|provider| provider.call_id.clone())
.unwrap_or_else(|| tool_call.id.as_str().to_owned()),
),
format: None,
index: None,
data: signature.clone(),
});
}
tool_calls.push(tool_call.into())
}
message::AssistantContent::Reasoning(r) => {
if r.content.is_empty() {
let display = r.display_text();
if !display.is_empty() {
reasoning = Some(display);
}
} else {
let reasoning_id = r.id.clone().filter(|id| !id.is_empty());
for reasoning_block in &r.content {
let index = Some(reasoning_details.len());
match reasoning_block {
message::ReasoningContent::Text { text, signature } => {
reasoning_details.push(ReasoningDetails::Text {
id: reasoning_id.clone(),
format: None,
index,
text: Some(text.clone()),
signature: signature.clone(),
});
}
message::ReasoningContent::Summary(summary) => {
reasoning_details.push(ReasoningDetails::Summary {
id: reasoning_id.clone(),
format: None,
index,
summary: summary.clone(),
});
}
message::ReasoningContent::Encrypted(data)
| message::ReasoningContent::Redacted { data } => {
reasoning_details.push(ReasoningDetails::Encrypted {
id: reasoning_id.clone(),
format: None,
index,
data: data.clone(),
});
}
}
}
}
}
message::AssistantContent::Image(image) if is_openrouter_response_image(&image) => {
}
message::AssistantContent::Image(_) => {
return Err(message::MessageError::ConversionError(
"OpenRouter does not support assistant image content in request history; pass images as user image inputs instead".into(),
));
}
}
}
if text_content.is_empty()
&& tool_calls.is_empty()
&& reasoning.is_none()
&& reasoning_details.is_empty()
{
return Ok(vec![]);
}
Ok(vec![Message::Assistant {
content: text_content
.into_iter()
.map(|content| content.text.into())
.collect::<Vec<_>>(),
refusal: None,
audio: None,
name: None,
tool_calls,
reasoning,
reasoning_details,
images: Vec::new(),
}])
}
pub fn messages_from_rig_message(
message: message::Message,
) -> Result<Vec<Message>, message::MessageError> {
match message {
message::Message::System { content } => Ok(vec![Message::system(&content)]),
message::Message::User { content } => user_contents_to_messages(content),
message::Message::Assistant { content, .. } => assistant_contents_to_messages(content),
}
}
pub(super) fn apply_prompt_caching(body: &mut serde_json::Value) {
let Some(obj) = body.as_object_mut() else {
return;
};
let Some(messages) = obj.get_mut("messages").and_then(|v| v.as_array_mut()) else {
return;
};
let Some(system_msg) = messages
.iter_mut()
.find(|m| m.get("role").and_then(|v| v.as_str()) == Some("system"))
else {
return;
};
match system_msg.get("content").cloned() {
Some(serde_json::Value::String(s)) => {
if let Some(obj) = system_msg.as_object_mut() {
obj.insert(
"content".to_string(),
serde_json::json!([{
"type": "text",
"text": s,
"cache_control": { "type": "ephemeral" }
}]),
);
}
}
Some(serde_json::Value::Array(mut arr)) => {
if let Some(last) = arr.last_mut()
&& let Some(obj) = last.as_object_mut()
{
obj.insert(
"cache_control".to_string(),
serde_json::json!({ "type": "ephemeral" }),
);
}
if let Some(obj) = system_msg.as_object_mut() {
obj.insert("content".to_string(), serde_json::Value::Array(arr));
}
}
_ => {}
}
}
pub(super) fn finalize_openrouter_request_body(body: &mut serde_json::Value, prompt_caching: bool) {
if prompt_caching {
apply_prompt_caching(body);
}
if let Some(messages) = body
.get_mut("messages")
.and_then(serde_json::Value::as_array_mut)
{
for message in messages {
if let Some(message) = message.as_object_mut()
&& message.get("role").and_then(serde_json::Value::as_str) == Some("assistant")
&& let Some(reasoning) = message.remove("reasoning_content")
{
message.insert("reasoning".to_string(), reasoning);
}
}
}
}
#[cfg(test)]
pub(super) fn final_request_body(
request: &OpenrouterCompletionRequest,
prompt_caching: bool,
) -> Result<serde_json::Value, CompletionError> {
let mut body = serde_json::to_value(request)?;
finalize_openrouter_request_body(&mut body, prompt_caching);
Ok(body)
}
pub(super) type OpenrouterCompletionRequest = openai::completion::CompletionRequest;
pub struct OpenRouterRequestParams<'a> {
pub model: &'a str,
pub request: CompletionRequest,
pub strict_tools: bool,
}
impl TryFrom<OpenRouterRequestParams<'_>> for OpenrouterCompletionRequest {
type Error = CompletionError;
fn try_from(params: OpenRouterRequestParams) -> Result<Self, Self::Error> {
let OpenRouterRequestParams {
model,
request: req,
strict_tools,
} = params;
let chat_history = req.chat_history_with_documents();
let model = req.model.clone().unwrap_or_else(|| model.to_string());
let mut full_history: Vec<Message> = match &req.preamble {
Some(preamble) => vec![Message::system(preamble)],
None => vec![],
};
let chat_history: Vec<Message> = chat_history
.into_iter()
.map(messages_from_rig_message)
.collect::<Result<Vec<Vec<Message>>, _>>()?
.into_iter()
.flatten()
.collect();
full_history.extend(chat_history);
let tool_choice = req
.tool_choice
.clone()
.map(crate::providers::openai::completion::ToolChoice::try_from)
.transpose()?;
let tools: Vec<crate::providers::openai::completion::ToolDefinition> = req
.tools
.clone()
.into_iter()
.map(|tool| {
let def = crate::providers::openai::completion::ToolDefinition::from(tool);
if strict_tools { def.with_strict() } else { def }
})
.collect();
let additional_params = if let Some(schema) = req.output_schema {
let name = schema
.as_object()
.and_then(|o| o.get("title"))
.and_then(|v| v.as_str())
.unwrap_or("response_schema")
.to_string();
let mut schema_value = schema.to_value();
openai::sanitize_schema(&mut schema_value);
let response_format = serde_json::json!({
"response_format": {
"type": "json_schema",
"json_schema": {
"name": name,
"strict": true,
"schema": schema_value
}
}
});
Some(match req.additional_params {
Some(existing) => json_utils::merge(existing, response_format),
None => response_format,
})
} else {
req.additional_params
};
Ok(Self {
model,
messages: full_history,
temperature: req.temperature,
max_tokens: req.max_tokens,
tools,
tool_choice,
additional_params,
})
}
}
impl TryFrom<(&str, CompletionRequest)> for OpenrouterCompletionRequest {
type Error = CompletionError;
fn try_from((model, req): (&str, CompletionRequest)) -> Result<Self, Self::Error> {
let model = req.model.clone().unwrap_or_else(|| model.to_string());
OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
model: &model,
request: req,
strict_tools: false,
})
}
}
impl openai::completion::OpenAICompatibleProvider for OpenRouterExt {
const PROVIDER_NAME: &'static str = self::PROVIDER_NAME;
type StreamingUsage = Usage;
type Response = CompletionResponse;
const STREAM_INCLUDE_USAGE: bool = false;
fn map_streaming_finish_reason(
&self,
finish_reason: Option<&str>,
native_finish_reason: Option<&str>,
) -> Option<crate::completion::FinishReason> {
if let Some(reason) = finish_reason.filter(|reason| !reason.is_empty()) {
return Some(map_openai_finish_reason(reason));
}
native_finish_reason
.filter(|reason| !reason.is_empty())
.map(map_native_finish_reason)
}
fn build_completion_request(
&self,
model: String,
request: CompletionRequest,
options: openai::completion::CompletionModelOptions,
) -> Result<openai::completion::CompletionRequest, CompletionError> {
OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
model: &model,
request,
strict_tools: options.strict_tools,
})
}
fn finalize_request_body_with_options(
&self,
body: &mut serde_json::Value,
options: openai::completion::CompletionModelOptions,
) -> Result<(), CompletionError> {
finalize_openrouter_request_body(body, options.prompt_caching);
Ok(())
}
fn streaming_detail_reasoning(
&self,
detail: &serde_json::Value,
) -> Option<(
crate::streaming::StreamPartId,
Option<crate::streaming::WireId>,
message::ReasoningContent,
)> {
let Ok(ReasoningDetails::Encrypted { id, data, .. }) =
serde_json::from_value::<ReasoningDetails>(detail.clone())
else {
return None;
};
let provider_id = id.and_then(crate::streaming::WireId::new);
let key = provider_id
.as_ref()
.map(|id| crate::streaming::StreamPartId::wire(id.as_str()))
.unwrap_or(crate::streaming::StreamPartId::minted(
crate::streaming::MintKind::EncryptedReasoning,
0,
));
Some((key, provider_id, message::ReasoningContent::Encrypted(data)))
}
fn streaming_reasoning_signature(&self, detail: &serde_json::Value) -> Option<String> {
let Ok(ReasoningDetails::Text {
signature: Some(signature),
..
}) = serde_json::from_value::<ReasoningDetails>(detail.clone())
else {
return None;
};
(!signature.is_empty()).then_some(signature)
}
}
pub type CompletionModel<H = reqwest::Client> =
openai::completion::GenericCompletionModel<OpenRouterExt, H>;
pub type StreamingCompletionResponse =
openai::completion::streaming::StreamingCompletionResponse<Usage>;
impl<H> openai::completion::GenericCompletionModel<OpenRouterExt, H> {
pub fn with_prompt_caching(mut self) -> Self {
self.prompt_caching = true;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::completion::NormalizeCompletionResponse;
use crate::message::{AudioMediaType, ImageDetail, VideoMediaType};
use serde_json::json;
#[test]
fn openrouter_client_constructs_a_completion_model() {
use crate::client::CompletionClient;
let client =
crate::providers::openrouter::Client::new("dummy-key").expect("Client::new() failed");
let model = client.completion_model(GEMINI_FLASH_2_0);
assert_eq!(model.model, GEMINI_FLASH_2_0);
}
#[test]
fn mixed_user_content_preserves_order_around_tool_results() {
let content = vec![
message::UserContent::text("before"),
message::UserContent::tool_result_with_call_id(
"result-id",
"call-id".to_string(),
"tool",
vec![message::ToolResultContent::text("tool output")],
),
message::UserContent::text("after"),
];
let messages = user_contents_to_messages(content).expect("message conversion");
assert!(matches!(
messages.as_slice(),
[
Message::User { content: before, .. },
Message::ToolResult { tool_call_id, .. },
Message::User { content: after, .. },
] if matches!(before.first(), Some(UserContent::Text { text }) if text == "before")
&& tool_call_id == "call-id"
&& matches!(after.first(), Some(UserContent::Text { text }) if text == "after")
));
}
#[test]
fn test_openrouter_request_uses_request_model_override() {
let request = CompletionRequest {
model: Some("google/gemini-2.5-flash".to_string()),
preamble: None,
chat_history: vec!["Hello".into()],
documents: vec![],
tools: vec![],
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
};
let openrouter_request =
OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
.expect("request conversion should succeed");
let serialized =
serde_json::to_value(openrouter_request).expect("serialization should succeed");
assert_eq!(serialized["model"], "google/gemini-2.5-flash");
}
#[test]
fn openrouter_request_carries_caller_max_tokens() {
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec!["Hello".into()],
documents: vec![],
tools: vec![],
temperature: None,
max_tokens: Some(512),
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
};
let openrouter_request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
model: "openai/gpt-4o-mini",
request,
strict_tools: false,
})
.expect("request conversion should succeed");
let serialized =
serde_json::to_value(openrouter_request).expect("serialization should succeed");
assert_eq!(serialized["max_tokens"], 512);
}
#[test]
fn openrouter_params_include_direct_request_documents() {
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec![crate::message::Message::user("What is glarb-glarb?")],
documents: vec![crate::completion::request::Document {
id: "doc_1".to_string(),
text: "Definition of glarb-glarb: an ancient tool.".to_string(),
additional_props: Default::default(),
}],
tools: vec![],
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
};
let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
model: "openai/gpt-4o-mini",
request,
strict_tools: false,
})
.expect("request conversion should succeed");
let serialized = serde_json::to_value(request).expect("serialization should succeed");
assert!(
serialized["messages"].to_string().contains("glarb-glarb"),
"direct request documents should be normalized through public params"
);
}
#[test]
fn test_openrouter_request_uses_default_model_when_override_unset() {
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec!["Hello".into()],
documents: vec![],
tools: vec![],
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
};
let openrouter_request =
OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
.expect("request conversion should succeed");
let serialized =
serde_json::to_value(openrouter_request).expect("serialization should succeed");
assert_eq!(serialized["model"], "openai/gpt-4o-mini");
}
#[test]
fn final_request_body_serializes_assistant_reasoning_under_openrouter_key() {
let request = OpenrouterCompletionRequest {
model: "openai/gpt-4o".to_string(),
messages: vec![Message::Assistant {
content: vec![],
reasoning: Some("thinking it through".to_string()),
refusal: None,
audio: None,
name: None,
tool_calls: vec![],
reasoning_details: vec![],
images: vec![],
}],
temperature: None,
max_tokens: None,
tools: vec![],
tool_choice: None,
additional_params: None,
};
let body = final_request_body(&request, false).expect("body should serialize");
assert_eq!(
body["messages"][0]["reasoning"],
serde_json::json!("thinking it through")
);
assert!(
body["messages"][0].get("reasoning_content").is_none(),
"OpenRouter's assistant reasoning key is `reasoning`, not `reasoning_content`"
);
}
#[test]
fn test_openrouter_request_maps_output_schema_to_response_format() {
let schema: schemars::Schema = serde_json::from_value(json!({
"title": "WeatherResponse",
"type": "object",
"properties": {
"city": { "type": "string" },
"weather": { "type": "string" }
}
}))
.expect("schema should deserialize");
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec!["Hello".into()],
documents: vec![],
tools: vec![],
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params: None,
output_schema: Some(schema),
record_telemetry_content: false,
};
let openrouter_request =
OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
.expect("request conversion should succeed");
let serialized =
serde_json::to_value(openrouter_request).expect("serialization should succeed");
assert_eq!(
serialized["response_format"],
json!({
"type": "json_schema",
"json_schema": {
"name": "WeatherResponse",
"strict": true,
"schema": {
"title": "WeatherResponse",
"type": "object",
"properties": {
"city": { "type": "string" },
"weather": { "type": "string" }
},
"additionalProperties": false,
"required": ["city", "weather"]
}
}
})
);
}
#[test]
fn test_openrouter_request_merges_output_schema_with_provider_preferences() {
let schema: schemars::Schema = serde_json::from_value(json!({
"type": "object",
"properties": {
"answer": { "type": "string" }
}
}))
.expect("schema should deserialize");
let request = CompletionRequest {
model: None,
preamble: None,
chat_history: vec!["Hello".into()],
documents: vec![],
tools: vec![],
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params: Some(
ProviderPreferences::new()
.require_parameters(true)
.to_json(),
),
output_schema: Some(schema),
record_telemetry_content: false,
};
let openrouter_request =
OpenrouterCompletionRequest::try_from(("openai/gpt-4o-mini", request))
.expect("request conversion should succeed");
let serialized =
serde_json::to_value(openrouter_request).expect("serialization should succeed");
assert_eq!(serialized["provider"]["require_parameters"], true);
assert_eq!(serialized["response_format"]["type"], "json_schema");
assert_eq!(
serialized["response_format"]["json_schema"]["name"],
"response_schema"
);
assert_eq!(
serialized["response_format"]["json_schema"]["schema"]["additionalProperties"],
false
);
}
#[test]
fn test_completion_response_deserialization_gemini_flash() {
let json = json!({
"id": "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA",
"provider": "Google",
"model": "google/gemini-2.5-flash",
"object": "chat.completion",
"created": 1765971703u64,
"choices": [{
"logprobs": null,
"finish_reason": "stop",
"native_finish_reason": "STOP",
"index": 0,
"message": {
"role": "assistant",
"content": "CONTENT",
"refusal": null,
"reasoning": null
}
}],
"usage": {
"prompt_tokens": 669,
"completion_tokens": 5,
"total_tokens": 674
}
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
assert_eq!(response.id, "gen-AAAAAAAAAA-AAAAAAAAAAAAAAAAAAAA");
assert_eq!(response.model, "google/gemini-2.5-flash");
assert_eq!(response.choices.len(), 1);
assert_eq!(response.choices[0].finish_reason, Some("stop".to_string()));
assert_eq!(response.choices[0].logprobs, None);
let serialized = serde_json::to_value(&response).unwrap();
assert!(
serialized["choices"][0].get("logprobs").is_none(),
"an absent optional native field stays absent when serialized"
);
}
#[test]
fn raw_completion_choice_retains_logprobs() {
let logprobs = json!({
"content": [{
"token": "cobalt",
"logprob": -0.01,
"bytes": [99],
"top_logprobs": []
}],
"refusal": null
});
let response: CompletionResponse = serde_json::from_value(json!({
"id": "gen-logprobs",
"object": "chat.completion",
"created": 1,
"model": "openai/gpt-4o-mini",
"system_fingerprint": null,
"choices": [{
"index": 0,
"native_finish_reason": "stop",
"finish_reason": "stop",
"message": {"role": "assistant", "content": "cobalt"},
"logprobs": logprobs
}],
"usage": null
}))
.expect("OpenRouter's documented probability object should decode");
assert_eq!(response.choices[0].logprobs, Some(logprobs));
}
#[test]
fn test_completion_response_usage_prefers_reported_completion_tokens() {
let json = json!({
"id": "gen-usage-divergent",
"object": "chat.completion",
"created": 1,
"model": "anthropic/claude-3.5-sonnet",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 500, "completion_tokens": 10, "total_tokens": 505}
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(converted.usage.output_tokens, 10);
}
#[test]
fn test_completion_response_usage_falls_back_when_completion_tokens_missing() {
let json = json!({
"id": "gen-usage-omitted",
"object": "chat.completion",
"created": 1,
"model": "some/gateway-model",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 100, "total_tokens": 110}
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(converted.usage.output_tokens, 10);
}
#[test]
fn test_completion_response_maps_cache_token_accounting() {
let json = json!({
"id": "gen-cache-test",
"object": "chat.completion",
"created": 1,
"model": "anthropic/claude-3.5-sonnet",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "Hi"
}
}],
"usage": {
"prompt_tokens": 500,
"completion_tokens": 10,
"total_tokens": 510,
"prompt_tokens_details": {
"cached_tokens": 400,
"cache_write_tokens": 50
}
}
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(converted.usage.input_tokens, 500);
assert_eq!(converted.usage.output_tokens, 10);
assert_eq!(converted.usage.cached_input_tokens, 400);
assert_eq!(converted.usage.cache_creation_input_tokens, 50);
}
#[test]
fn test_completion_response_cache_tokens_absent_defaults_to_zero() {
let json = json!({
"id": "gen-no-cache",
"object": "chat.completion",
"created": 1,
"model": "openai/gpt-4o",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "Hi"
}
}],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 10,
"total_tokens": 110
}
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(converted.usage.cached_input_tokens, 0);
assert_eq!(converted.usage.cache_creation_input_tokens, 0);
}
#[test]
fn test_completion_response_deserialization_gemini_model_role() {
let json = json!({
"id": "gen-BBBBBBBBBB-BBBBBBBBBBBBBBBBBBBB",
"provider": "Google",
"model": "google/gemini-2.5-pro-exp-03-25:free",
"object": "chat.completion",
"created": 1743780565u64,
"choices": [{
"logprobs": null,
"finish_reason": "stop",
"native_finish_reason": "STOP",
"index": 0,
"message": {
"role": "model",
"content": "CONTENT",
"refusal": null,
"reasoning": null
}
}],
"usage": {
"prompt_tokens": 669,
"completion_tokens": 5,
"total_tokens": 674
}
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(
converted.model.as_deref(),
Some("google/gemini-2.5-pro-exp-03-25:free")
);
assert_eq!(converted.provider, "openrouter");
assert!(matches!(
converted.choice.first(),
Some(completion::AssistantContent::Text(text)) if text.text == "CONTENT"
));
}
#[test]
fn openrouter_finish_reasons_map_and_preserve_unknown_values() {
use crate::completion::FinishReason;
let choice = |finish_reason: Option<&str>, native: Option<&str>| Choice {
index: 0,
native_finish_reason: native.map(str::to_string),
message: Message::Assistant {
content: vec![],
reasoning: None,
refusal: None,
audio: None,
name: None,
tool_calls: vec![],
reasoning_details: vec![],
images: vec![],
},
finish_reason: finish_reason.map(str::to_string),
logprobs: None,
};
assert_eq!(
map_finish_reason(&choice(Some("stop"), Some("STOP"))),
Some(FinishReason::Stop)
);
assert_eq!(
map_finish_reason(&choice(Some("length"), None)),
Some(FinishReason::Length)
);
assert_eq!(
map_finish_reason(&choice(Some("tool_calls"), None)),
Some(FinishReason::ToolCalls)
);
assert_eq!(
map_finish_reason(&choice(Some("content_filter"), None)),
Some(FinishReason::ContentFilter)
);
assert_eq!(
map_finish_reason(&choice(None, Some("completed"))),
Some(FinishReason::Stop)
);
assert_eq!(
map_finish_reason(&choice(None, Some("max_output_tokens"))),
Some(FinishReason::Length)
);
assert_eq!(
map_finish_reason(&choice(Some("error"), None)),
Some(FinishReason::Other("error".to_string()))
);
assert_eq!(
map_finish_reason(&choice(None, Some("MALFORMED_FUNCTION_CALL"))),
Some(FinishReason::Other("MALFORMED_FUNCTION_CALL".to_string()))
);
assert_eq!(map_finish_reason(&choice(None, None)), None);
}
#[test]
fn openrouter_stop_with_tool_call_reports_tool_calls() {
let json = json!({
"id": "gen-tool",
"object": "chat.completion",
"created": 1,
"model": "anthropic/claude-3.5-sonnet",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"}
}]
}
}]
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(
converted.finish_reason(),
Some(crate::completion::FinishReason::ToolCalls)
);
}
#[test]
fn openrouter_truncated_tool_arguments_do_not_destroy_the_response() {
let json = json!({
"id": "gen-truncated",
"object": "chat.completion",
"created": 1,
"model": "deepseek/deepseek-chat",
"choices": [{
"index": 0,
"finish_reason": "length",
"message": {
"role": "assistant",
"content": "Acknowledged.",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "page", "arguments": "{\"team\":\"platform\"}"}
},
{
"id": "call_2",
"type": "function",
"function": {"name": "file_report", "arguments": "{\"summary\": "}
}
]
}
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 24, "total_tokens": 34}
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(
converted.finish_reason(),
Some(crate::completion::FinishReason::Length)
);
let names = converted
.choice
.iter()
.filter_map(|content| match content {
completion::AssistantContent::ToolCall(call) => Some(call.function.name.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(names, vec!["page"], "only the truncated call is dropped");
assert!(
converted.choice.iter().any(|content| matches!(
content,
completion::AssistantContent::Text(text) if text.text == "Acknowledged."
)),
"the turn's text survives: {:?}",
converted.choice
);
assert_eq!(converted.usage.total_tokens, 34);
}
#[test]
fn openrouter_native_length_fallback_tolerates_truncated_tool_arguments() {
let json = json!({
"id": "gen-native-truncated",
"object": "chat.completion",
"created": 1,
"model": "anthropic/claude-haiku-4.5",
"choices": [{
"index": 0,
"finish_reason": null,
"native_finish_reason": "max_output_tokens",
"message": {
"role": "assistant",
"content": "still useful",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{\"q\":"}
}]
}
}]
});
let response: CompletionResponse = serde_json::from_value(json)
.expect("the native terminal reason should authorize narrow truncation tolerance");
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(
converted.finish_reason(),
Some(crate::completion::FinishReason::Length)
);
assert!(
converted
.choice
.iter()
.all(|content| !matches!(content, completion::AssistantContent::ToolCall(_)))
);
assert!(matches!(
converted.choice.first(),
Some(completion::AssistantContent::Text(text)) if text.text == "still useful"
));
}
#[test]
fn openrouter_length_preserves_an_empty_turn_after_dropping_its_only_call() {
for (finish_reason, native_finish_reason) in
[(Some("length"), None), (None, Some("max_output_tokens"))]
{
let response: CompletionResponse = serde_json::from_value(json!({
"id": "gen-empty-truncated",
"object": "chat.completion",
"created": 1,
"model": "openai/gpt-4.1-mini",
"choices": [{
"index": 0,
"finish_reason": finish_reason,
"native_finish_reason": native_finish_reason,
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": ""}
}]
}
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 1, "total_tokens": 11}
}))
.expect("outer length should permit dropping the incomplete call");
let converted = response
.normalize(PROVIDER_NAME)
.expect("an empty truncated turn still carries its diagnostic");
assert!(converted.choice.is_empty());
assert_eq!(
converted.finish_reason(),
Some(crate::completion::FinishReason::Length)
);
assert_eq!(converted.usage.total_tokens, 11);
assert_eq!(
converted.response_id.as_deref(),
Some("gen-empty-truncated")
);
}
}
#[test]
fn openrouter_content_filter_preserves_an_empty_turn() {
let response: CompletionResponse = serde_json::from_value(json!({
"id": "gen-filtered",
"object": "chat.completion",
"created": 1,
"model": "openai/gpt-4.1-mini",
"choices": [{
"index": 0,
"finish_reason": "content_filter",
"message": {"role": "assistant", "content": null}
}]
}))
.unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert!(converted.choice.is_empty());
assert_eq!(
converted.finish_reason(),
Some(crate::completion::FinishReason::ContentFilter)
);
}
#[test]
fn openrouter_malformed_completed_tool_arguments_remain_loud() {
let json = json!({
"id": "gen-malformed",
"object": "chat.completion",
"created": 1,
"model": "deepseek/deepseek-chat",
"choices": [{
"index": 0,
"finish_reason": "tool_calls",
"native_finish_reason": "max_output_tokens",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{\"q\":"}
}]
}
}]
});
assert!(
serde_json::from_value::<CompletionResponse>(json).is_err(),
"only an outer output-length reason authorizes truncation tolerance"
);
}
#[tokio::test]
async fn streaming_native_length_fallback_drops_partial_tool_call() {
use crate::client::CompletionClient;
use crate::completion::CompletionModel as _;
use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
use crate::streaming::StreamedAssistantContent;
use crate::test_utils::MockStreamingClient;
use futures::StreamExt;
let http_client = MockStreamingClient {
sse_bytes: sse_bytes_from_data_lines([
r#"{"id":"gen-native-truncated","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"still useful","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
r#"{"id":"gen-native-truncated","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":null,"native_finish_reason":"max_output_tokens"}]}"#,
"[DONE]",
]),
};
let client = crate::providers::openrouter::Client::builder()
.api_key("dummy-key")
.http_client(http_client)
.build()
.expect("client should build");
let model = client.completion_model("anthropic/claude-haiku-4.5");
let request = model.completion_request("lookup").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut terminal = None;
let mut saw_tool_call = false;
while let Some(item) = stream.next().await {
match item.expect("native max_tokens truncation is tolerated") {
StreamedAssistantContent::ToolCall { .. } => saw_tool_call = true,
StreamedAssistantContent::Final(final_record) => terminal = Some(final_record),
_ => {}
}
}
assert!(
!saw_tool_call,
"the partial call must not become executable"
);
assert_eq!(
terminal.and_then(|record| record.finish_reason),
Some(crate::completion::FinishReason::Length)
);
}
#[tokio::test]
async fn streaming_normalized_reason_wins_over_native_length() {
use crate::client::CompletionClient;
use crate::completion::CompletionModel as _;
use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
use crate::streaming::StreamedAssistantContent;
use crate::test_utils::MockStreamingClient;
use futures::StreamExt;
let http_client = MockStreamingClient {
sse_bytes: sse_bytes_from_data_lines([
r#"{"id":"gen-normalized-wins","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"q\":"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
r#"{"id":"gen-normalized-wins","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls","native_finish_reason":"max_output_tokens"}]}"#,
"[DONE]",
]),
};
let client = crate::providers::openrouter::Client::builder()
.api_key("dummy-key")
.http_client(http_client)
.build()
.expect("client should build");
let model = client.completion_model("anthropic/claude-haiku-4.5");
let request = model.completion_request("lookup").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut terminal = None;
let mut errors = Vec::new();
while let Some(item) = stream.next().await {
match item {
Ok(StreamedAssistantContent::Final(final_record)) => terminal = Some(final_record),
Ok(_) => {}
Err(error) => errors.push(error.to_string()),
}
}
assert_eq!(errors.len(), 1, "the completed malformed call stays loud");
assert!(errors[0].contains("malformed JSON input"), "{}", errors[0]);
assert_eq!(
terminal.and_then(|record| record.finish_reason),
Some(crate::completion::FinishReason::ToolCalls)
);
}
#[test]
fn test_message_assistant_without_reasoning_details() {
let json = json!({
"role": "assistant",
"content": "Hello world",
"refusal": null,
"reasoning": null
});
let message: Message = serde_json::from_value(json).unwrap();
match message {
Message::Assistant {
content,
reasoning_details,
..
} => {
assert_eq!(content.len(), 1);
assert!(reasoning_details.is_empty());
}
_ => panic!("Expected Assistant message"),
}
}
#[test]
fn test_data_collection_serialization() {
assert_eq!(
serde_json::to_string(&DataCollection::Allow).unwrap(),
r#""allow""#
);
assert_eq!(
serde_json::to_string(&DataCollection::Deny).unwrap(),
r#""deny""#
);
}
#[test]
fn test_data_collection_default() {
assert_eq!(DataCollection::default(), DataCollection::Allow);
}
#[test]
fn test_quantization_serialization() {
assert_eq!(
serde_json::to_string(&Quantization::Int4).unwrap(),
r#""int4""#
);
assert_eq!(
serde_json::to_string(&Quantization::Int8).unwrap(),
r#""int8""#
);
assert_eq!(
serde_json::to_string(&Quantization::Fp16).unwrap(),
r#""fp16""#
);
assert_eq!(
serde_json::to_string(&Quantization::Bf16).unwrap(),
r#""bf16""#
);
assert_eq!(
serde_json::to_string(&Quantization::Fp32).unwrap(),
r#""fp32""#
);
assert_eq!(
serde_json::to_string(&Quantization::Fp8).unwrap(),
r#""fp8""#
);
assert_eq!(
serde_json::to_string(&Quantization::Unknown).unwrap(),
r#""unknown""#
);
}
#[test]
fn test_provider_sort_strategy_serialization() {
assert_eq!(
serde_json::to_string(&ProviderSortStrategy::Price).unwrap(),
r#""price""#
);
assert_eq!(
serde_json::to_string(&ProviderSortStrategy::Throughput).unwrap(),
r#""throughput""#
);
assert_eq!(
serde_json::to_string(&ProviderSortStrategy::Latency).unwrap(),
r#""latency""#
);
}
#[test]
fn test_sort_partition_serialization() {
assert_eq!(
serde_json::to_string(&SortPartition::Model).unwrap(),
r#""model""#
);
assert_eq!(
serde_json::to_string(&SortPartition::None).unwrap(),
r#""none""#
);
}
#[test]
fn test_provider_sort_simple() {
let sort = ProviderSort::Simple(ProviderSortStrategy::Latency);
let json = serde_json::to_value(&sort).unwrap();
assert_eq!(json, "latency");
}
#[test]
fn test_provider_sort_complex() {
let sort = ProviderSort::Complex(
ProviderSortConfig::new(ProviderSortStrategy::Price).partition(SortPartition::None),
);
let json = serde_json::to_value(&sort).unwrap();
assert_eq!(json["by"], "price");
assert_eq!(json["partition"], "none");
}
#[test]
fn test_provider_sort_complex_without_partition() {
let sort = ProviderSort::Complex(ProviderSortConfig::new(ProviderSortStrategy::Throughput));
let json = serde_json::to_value(&sort).unwrap();
assert_eq!(json["by"], "throughput");
assert!(json.get("partition").is_none());
}
#[test]
fn test_provider_sort_from_strategy() {
let sort: ProviderSort = ProviderSortStrategy::Price.into();
assert_eq!(sort, ProviderSort::Simple(ProviderSortStrategy::Price));
}
#[test]
fn test_provider_sort_from_config() {
let config = ProviderSortConfig::new(ProviderSortStrategy::Latency);
let sort: ProviderSort = config.into();
match sort {
ProviderSort::Complex(c) => assert_eq!(c.by, ProviderSortStrategy::Latency),
_ => panic!("Expected Complex variant"),
}
}
#[test]
fn test_percentile_thresholds_builder() {
let thresholds = PercentileThresholds::new()
.p50(10.0)
.p75(25.0)
.p90(50.0)
.p99(100.0);
assert_eq!(thresholds.p50, Some(10.0));
assert_eq!(thresholds.p75, Some(25.0));
assert_eq!(thresholds.p90, Some(50.0));
assert_eq!(thresholds.p99, Some(100.0));
}
#[test]
fn test_percentile_thresholds_default() {
let thresholds = PercentileThresholds::default();
assert_eq!(thresholds.p50, None);
assert_eq!(thresholds.p75, None);
assert_eq!(thresholds.p90, None);
assert_eq!(thresholds.p99, None);
}
#[test]
fn test_throughput_threshold_simple() {
let threshold = ThroughputThreshold::Simple(50.0);
let json = serde_json::to_value(&threshold).unwrap();
assert_eq!(json, 50.0);
}
#[test]
fn test_throughput_threshold_percentile() {
let threshold = ThroughputThreshold::Percentile(PercentileThresholds::new().p90(50.0));
let json = serde_json::to_value(&threshold).unwrap();
assert_eq!(json["p90"], 50.0);
}
#[test]
fn test_latency_threshold_simple() {
let threshold = LatencyThreshold::Simple(0.5);
let json = serde_json::to_value(&threshold).unwrap();
assert_eq!(json, 0.5);
}
#[test]
fn test_latency_threshold_percentile() {
let threshold = LatencyThreshold::Percentile(PercentileThresholds::new().p50(0.1).p99(1.0));
let json = serde_json::to_value(&threshold).unwrap();
assert_eq!(json["p50"], 0.1);
assert_eq!(json["p99"], 1.0);
}
#[test]
fn test_max_price_builder() {
let price = MaxPrice::new().prompt(0.001).completion(0.002);
assert_eq!(price.prompt, Some(0.001));
assert_eq!(price.completion, Some(0.002));
assert_eq!(price.request, None);
assert_eq!(price.image, None);
}
#[test]
fn test_max_price_all_fields() {
let price = MaxPrice::new()
.prompt(0.001)
.completion(0.002)
.request(0.01)
.image(0.05);
let json = serde_json::to_value(&price).unwrap();
assert_eq!(json["prompt"], 0.001);
assert_eq!(json["completion"], 0.002);
assert_eq!(json["request"], 0.01);
assert_eq!(json["image"], 0.05);
}
#[test]
fn test_max_price_default() {
let price = MaxPrice::default();
assert_eq!(price.prompt, None);
assert_eq!(price.completion, None);
assert_eq!(price.request, None);
assert_eq!(price.image, None);
}
#[test]
fn test_provider_preferences_default() {
let prefs = ProviderPreferences::default();
assert!(prefs.order.is_none());
assert!(prefs.only.is_none());
assert!(prefs.ignore.is_none());
assert!(prefs.allow_fallbacks.is_none());
assert!(prefs.require_parameters.is_none());
assert!(prefs.data_collection.is_none());
assert!(prefs.zdr.is_none());
assert!(prefs.sort.is_none());
assert!(prefs.preferred_min_throughput.is_none());
assert!(prefs.preferred_max_latency.is_none());
assert!(prefs.max_price.is_none());
assert!(prefs.quantizations.is_none());
}
#[test]
fn test_provider_preferences_order_with_fallbacks() {
let prefs = ProviderPreferences::new()
.order(["anthropic", "openai"])
.allow_fallbacks(true);
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["order"], json!(["anthropic", "openai"]));
assert_eq!(provider["allow_fallbacks"], true);
}
#[test]
fn test_provider_preferences_only_allowlist() {
let prefs = ProviderPreferences::new()
.only(["azure", "together"])
.allow_fallbacks(false);
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["only"], json!(["azure", "together"]));
assert_eq!(provider["allow_fallbacks"], false);
}
#[test]
fn test_provider_preferences_ignore() {
let prefs = ProviderPreferences::new().ignore(["deepinfra"]);
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["ignore"], json!(["deepinfra"]));
}
#[test]
fn test_provider_preferences_sort_latency() {
let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Latency);
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["sort"], "latency");
}
#[test]
fn test_provider_preferences_price_with_throughput() {
let prefs = ProviderPreferences::new()
.sort(ProviderSortStrategy::Price)
.preferred_min_throughput(ThroughputThreshold::Percentile(
PercentileThresholds::new().p90(50.0),
));
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["sort"], "price");
assert_eq!(provider["preferred_min_throughput"]["p90"], 50.0);
}
#[test]
fn test_provider_preferences_require_parameters() {
let prefs = ProviderPreferences::new().require_parameters(true);
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["require_parameters"], true);
}
#[test]
fn test_provider_preferences_data_policy_and_zdr() {
let prefs = ProviderPreferences::new()
.data_collection(DataCollection::Deny)
.zdr(true);
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["data_collection"], "deny");
assert_eq!(provider["zdr"], true);
}
#[test]
fn test_provider_preferences_quantizations() {
let prefs =
ProviderPreferences::new().quantizations([Quantization::Int8, Quantization::Fp16]);
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["quantizations"], json!(["int8", "fp16"]));
}
#[test]
fn test_provider_preferences_convenience_methods() {
let prefs = ProviderPreferences::new().zero_data_retention().fastest();
assert_eq!(prefs.zdr, Some(true));
assert_eq!(
prefs.sort,
Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
);
let prefs2 = ProviderPreferences::new().cheapest();
assert_eq!(
prefs2.sort,
Some(ProviderSort::Simple(ProviderSortStrategy::Price))
);
let prefs3 = ProviderPreferences::new().lowest_latency();
assert_eq!(
prefs3.sort,
Some(ProviderSort::Simple(ProviderSortStrategy::Latency))
);
}
#[test]
fn test_provider_preferences_serialization_skips_none() {
let prefs = ProviderPreferences::new().sort(ProviderSortStrategy::Price);
let json = serde_json::to_value(&prefs).unwrap();
assert_eq!(json["sort"], "price");
assert!(json.get("order").is_none());
assert!(json.get("only").is_none());
assert!(json.get("ignore").is_none());
assert!(json.get("zdr").is_none());
}
#[test]
fn test_provider_preferences_deserialization() {
let json = json!({
"order": ["anthropic", "openai"],
"sort": "throughput",
"data_collection": "deny",
"zdr": true,
"quantizations": ["int8", "fp16"]
});
let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
assert_eq!(
prefs.order,
Some(vec!["anthropic".to_string(), "openai".to_string()])
);
assert_eq!(
prefs.sort,
Some(ProviderSort::Simple(ProviderSortStrategy::Throughput))
);
assert_eq!(prefs.data_collection, Some(DataCollection::Deny));
assert_eq!(prefs.zdr, Some(true));
assert_eq!(
prefs.quantizations,
Some(vec![Quantization::Int8, Quantization::Fp16])
);
}
#[test]
fn test_provider_preferences_deserialization_complex_sort() {
let json = json!({
"sort": {
"by": "latency",
"partition": "model"
}
});
let prefs: ProviderPreferences = serde_json::from_value(json).unwrap();
match prefs.sort {
Some(ProviderSort::Complex(config)) => {
assert_eq!(config.by, ProviderSortStrategy::Latency);
assert_eq!(config.partition, Some(SortPartition::Model));
}
_ => panic!("Expected Complex sort variant"),
}
}
#[test]
fn test_provider_preferences_full_integration() {
let prefs = ProviderPreferences::new()
.order(["anthropic", "openai"])
.only(["anthropic", "openai", "google"])
.sort(ProviderSortStrategy::Throughput)
.data_collection(DataCollection::Deny)
.zdr(true)
.quantizations([Quantization::Int8])
.allow_fallbacks(false);
let json = prefs.to_json();
assert!(json.get("provider").is_some());
let provider = &json["provider"];
assert_eq!(provider["order"], json!(["anthropic", "openai"]));
assert_eq!(provider["only"], json!(["anthropic", "openai", "google"]));
assert_eq!(provider["sort"], "throughput");
assert_eq!(provider["data_collection"], "deny");
assert_eq!(provider["zdr"], true);
assert_eq!(provider["quantizations"], json!(["int8"]));
assert_eq!(provider["allow_fallbacks"], false);
}
#[test]
fn test_provider_preferences_max_price() {
let prefs =
ProviderPreferences::new().max_price(MaxPrice::new().prompt(0.001).completion(0.002));
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["max_price"]["prompt"], 0.001);
assert_eq!(provider["max_price"]["completion"], 0.002);
}
#[test]
fn test_provider_preferences_preferred_max_latency() {
let prefs = ProviderPreferences::new().preferred_max_latency(LatencyThreshold::Simple(0.5));
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["preferred_max_latency"], 0.5);
}
#[test]
fn test_provider_preferences_empty_arrays() {
let prefs = ProviderPreferences::new()
.order(Vec::<String>::new())
.quantizations(Vec::<Quantization>::new());
let json = prefs.to_json();
let provider = &json["provider"];
assert_eq!(provider["order"], json!([]));
assert_eq!(provider["quantizations"], json!([]));
}
#[test]
fn test_user_content_text_serialization() {
let content = UserContent::Text {
text: "Hello, world!".to_string(),
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "Hello, world!");
}
#[test]
fn test_user_content_image_url_serialization() {
let content = UserContent::Image {
image_url: ImageUrl {
url: "https://example.com/image.png".to_string(),
detail: None,
},
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "image_url");
assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
assert!(json["image_url"].get("detail").is_none());
}
#[test]
fn test_user_content_image_url_with_detail_serialization() {
let content = UserContent::Image {
image_url: ImageUrl {
url: "https://example.com/image.png".to_string(),
detail: Some(ImageDetail::High),
},
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "image_url");
assert_eq!(json["image_url"]["url"], "https://example.com/image.png");
assert_eq!(json["image_url"]["detail"], "high");
}
#[test]
fn test_user_content_image_base64_serialization() {
let content = UserContent::Image {
image_url: ImageUrl {
url: "data:image/png;base64,SGVsbG8=".to_string(),
detail: Some(ImageDetail::Low),
},
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "image_url");
assert_eq!(json["image_url"]["url"], "data:image/png;base64,SGVsbG8=");
assert_eq!(json["image_url"]["detail"], "low");
}
#[test]
fn test_user_content_file_url_serialization() {
let content = UserContent::File {
file: FileData {
file_data: Some("https://example.com/doc.pdf".to_string()),
file_id: None,
filename: Some("document.pdf".to_string()),
},
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "file");
assert_eq!(json["file"]["file_data"], "https://example.com/doc.pdf");
assert_eq!(json["file"]["filename"], "document.pdf");
}
#[test]
fn test_user_content_file_base64_serialization() {
let content = UserContent::File {
file: FileData {
file_data: Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string()),
file_id: None,
filename: Some("report.pdf".to_string()),
},
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "file");
assert_eq!(
json["file"]["file_data"],
"data:application/pdf;base64,JVBERi0xLjQ="
);
assert_eq!(json["file"]["filename"], "report.pdf");
}
#[test]
fn test_user_content_text_deserialization() {
let json = json!({
"type": "text",
"text": "Hello!"
});
let content: UserContent = serde_json::from_value(json).unwrap();
assert_eq!(
content,
UserContent::Text {
text: "Hello!".to_string()
}
);
}
#[test]
fn test_user_content_image_url_deserialization() {
let json = json!({
"type": "image_url",
"image_url": {
"url": "https://example.com/img.jpg",
"detail": "high"
}
});
let content: UserContent = serde_json::from_value(json).unwrap();
match content {
UserContent::Image { image_url } => {
assert_eq!(image_url.url, "https://example.com/img.jpg");
assert_eq!(image_url.detail, Some(ImageDetail::High));
}
_ => panic!("Expected Image variant"),
}
}
#[test]
fn test_user_content_file_deserialization() {
let json = json!({
"type": "file",
"file": {
"filename": "doc.pdf",
"file_data": "https://example.com/doc.pdf"
}
});
let content: UserContent = serde_json::from_value(json).unwrap();
match content {
UserContent::File { file } => {
assert_eq!(file.filename, Some("doc.pdf".to_string()));
assert_eq!(
file.file_data,
Some("https://example.com/doc.pdf".to_string())
);
}
_ => panic!("Expected File variant"),
}
}
#[test]
fn test_message_user_with_text_serialization() {
let message = Message::User {
content: vec![UserContent::Text {
text: "Hello".to_string(),
}],
name: None,
};
let json = serde_json::to_value(&message).unwrap();
assert_eq!(json["role"], "user");
assert_eq!(json["content"], "Hello");
}
#[test]
fn test_message_user_with_mixed_content_serialization() {
let message = Message::User {
content: vec![
UserContent::Text {
text: "Check this image:".to_string(),
},
UserContent::Image {
image_url: ImageUrl {
url: "https://example.com/img.png".to_string(),
detail: None,
},
},
],
name: None,
};
let json = serde_json::to_value(&message).unwrap();
assert_eq!(json["role"], "user");
let content = json["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "image_url");
}
#[test]
fn test_message_user_with_file_serialization() {
let message = Message::User {
content: vec![
UserContent::Text {
text: "Analyze this PDF:".to_string(),
},
UserContent::File {
file: FileData {
file_data: Some("https://example.com/doc.pdf".to_string()),
file_id: None,
filename: Some("document.pdf".to_string()),
},
},
],
name: None,
};
let json = serde_json::to_value(&message).unwrap();
assert_eq!(json["role"], "user");
let content = json["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "file");
assert_eq!(
content[1]["file"]["file_data"],
"https://example.com/doc.pdf"
);
}
#[test]
fn test_user_content_from_rig_text() {
let rig_content = message::UserContent::Text(message::Text::new("Hello".to_string()));
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
assert_eq!(
openrouter_content,
UserContent::Text {
text: "Hello".to_string()
}
);
}
#[test]
fn test_user_content_from_rig_image_url() {
let rig_content = message::UserContent::Image(message::Image {
data: DocumentSourceKind::Url("https://example.com/img.png".to_string()),
media_type: Some(message::ImageMediaType::PNG),
detail: Some(ImageDetail::High),
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::Image { image_url } => {
assert_eq!(image_url.url, "https://example.com/img.png");
assert_eq!(image_url.detail, Some(ImageDetail::High));
}
_ => panic!("Expected Image variant"),
}
}
#[test]
fn test_user_content_from_rig_image_base64() {
let rig_content = message::UserContent::Image(message::Image {
data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
media_type: Some(message::ImageMediaType::JPEG),
detail: Some(ImageDetail::Low),
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::Image { image_url } => {
assert_eq!(image_url.url, "data:image/jpeg;base64,SGVsbG8=");
assert_eq!(image_url.detail, Some(ImageDetail::Low));
}
_ => panic!("Expected Image variant"),
}
}
#[test]
fn test_user_content_from_rig_document_url() {
let rig_content = message::UserContent::Document(message::Document {
data: DocumentSourceKind::Url("https://example.com/doc.pdf".to_string()),
media_type: Some(DocumentMediaType::PDF),
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::File { file } => {
assert_eq!(
file.file_data,
Some("https://example.com/doc.pdf".to_string())
);
assert_eq!(file.filename, Some("document.pdf".to_string()));
}
_ => panic!("Expected File variant"),
}
}
#[test]
fn test_user_content_from_rig_document_base64() {
let rig_content = message::UserContent::Document(message::Document {
data: DocumentSourceKind::Base64("JVBERi0xLjQ=".to_string()),
media_type: Some(DocumentMediaType::PDF),
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::File { file } => {
assert_eq!(
file.file_data,
Some("data:application/pdf;base64,JVBERi0xLjQ=".to_string())
);
assert_eq!(file.filename, Some("document.pdf".to_string()));
}
_ => panic!("Expected File variant"),
}
}
#[test]
fn test_user_content_from_rig_document_file_id() {
let rig_content = message::UserContent::Document(message::Document {
data: DocumentSourceKind::FileId("file_abc".to_string()),
media_type: None,
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(matches!(
result,
Err(message::MessageError::ConversionError(message))
if message.contains("Provider file IDs are not supported")
));
}
#[test]
fn test_openai_file_id_content_round_trips_through_rig_to_openrouter_error() {
let openai_content = openai::UserContent::File {
file: openai::FileData {
file_data: None,
file_id: Some("file_abc".to_string()),
filename: None,
},
};
let rig_content: message::UserContent = openai_content.into();
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(matches!(
result,
Err(message::MessageError::ConversionError(message))
if message.contains("Provider file IDs are not supported")
));
}
#[test]
fn test_user_content_from_rig_document_string_becomes_text() {
let rig_content = message::UserContent::Document(message::Document {
data: DocumentSourceKind::String("Plain text document content".to_string()),
media_type: Some(DocumentMediaType::TXT),
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
assert_eq!(
openrouter_content,
UserContent::Text {
text: "Plain text document content".to_string()
}
);
}
#[test]
fn test_completion_response_with_reasoning_details_maps_to_typed_reasoning() {
let json = json!({
"id": "resp_123",
"object": "chat.completion",
"created": 1,
"model": "openrouter/test-model",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "hello",
"reasoning": null,
"reasoning_details": [
{"type":"reasoning.summary","id":"rs_1","summary":"s1"},
{"type":"reasoning.text","id":"rs_1","text":"t1","signature":"sig_1"},
{"type":"reasoning.encrypted","id":"rs_1","data":"enc_1"}
],
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"}
}]
}
}]
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
assert_eq!(items.len(), 3, "reasoning, text, then tool call");
assert!(matches!(
&items[0],
completion::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
if id == "rs_1" && content.len() == 3
));
assert!(matches!(
&items[1],
completion::AssistantContent::Text(text) if text.text == "hello"
));
assert!(matches!(
&items[2],
completion::AssistantContent::ToolCall(call) if call.function.name == "lookup"
));
}
#[tokio::test]
async fn streaming_encrypted_reasoning_detail_reaches_the_choice_and_replays() {
use crate::client::CompletionClient;
use crate::completion::CompletionModel as _;
use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
use crate::streaming::StreamedAssistantContent;
use crate::test_utils::MockStreamingClient;
use futures::StreamExt;
let http_client = MockStreamingClient {
sse_bytes: sse_bytes_from_data_lines([
r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","id":"rs_1","format":"openai-responses-v1","index":0,"data":"enc_blob"}]},"finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":\"Tokyo\"}"}}]},"finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}"#,
"[DONE]",
]),
};
let client = crate::providers::openrouter::Client::builder()
.api_key("dummy-key")
.http_client(http_client)
.build()
.expect("client should build");
let model = client.completion_model("openai/o4-mini");
let request = model.completion_request("weather?").build();
let mut stream = model.stream(request).await.expect("stream should start");
let mut events: Vec<&'static str> = Vec::new();
let mut streamed_tool_calls = Vec::new();
while let Some(chunk) = stream.next().await {
match chunk.expect("stream item should be ok") {
StreamedAssistantContent::Reasoning { reasoning, .. } => {
assert_eq!(reasoning.id.as_deref(), Some("rs_1"));
assert!(matches!(
reasoning.content.first(),
Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
));
events.push("reasoning");
}
StreamedAssistantContent::ToolCall { tool_call, .. } => {
streamed_tool_calls.push(tool_call);
events.push("tool_call");
}
_ => {}
}
}
assert_eq!(events, vec!["reasoning", "tool_call"]);
let tool_call = streamed_tool_calls.first().expect("streamed tool call");
assert_eq!(tool_call.id, "call_1");
assert!(tool_call.signature.is_none());
assert!(tool_call.additional_params.is_none());
let choice: Vec<message::AssistantContent> = stream.choice.clone().into_iter().collect();
assert!(
choice.iter().any(|content| matches!(
content,
message::AssistantContent::Reasoning(message::Reasoning { id: Some(id), content })
if id == "rs_1"
&& matches!(
content.first(),
Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
)
)),
"encrypted reasoning must reach the aggregated choice: {choice:#?}"
);
let messages =
assistant_contents_to_messages(stream.choice.clone()).expect("history conversion");
let Message::Assistant {
reasoning_details, ..
} = messages.first().expect("assistant message")
else {
panic!("Expected assistant message");
};
assert!(
reasoning_details.iter().any(|detail| matches!(
detail,
ReasoningDetails::Encrypted { id: Some(id), data, .. }
if id == "rs_1" && data == "enc_blob"
)),
"encrypted reasoning must replay as a reasoning_details entry: {reasoning_details:#?}"
);
}
#[tokio::test]
async fn streaming_anthropic_reasoning_signature_reaches_choice_and_replays() {
use crate::client::CompletionClient;
use crate::completion::CompletionModel as _;
use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
use crate::test_utils::MockStreamingClient;
use futures::StreamExt;
let http_client = MockStreamingClient {
sse_bytes: sse_bytes_from_data_lines([
r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"think first","reasoning_details":[{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"text":"think first"}]},"finish_reason":null,"native_finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_details":[{"type":"reasoning.text","format":"anthropic-claude-v1","index":0,"signature":"sig-live-shape"}]},"finish_reason":null,"native_finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"toolu_1","type":"function","function":{"name":"lookup","arguments":"{}"}}]},"finish_reason":null,"native_finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"anthropic/claude-haiku-4.5","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls","native_finish_reason":"tool_use"}]}"#,
"[DONE]",
]),
};
let client = crate::providers::openrouter::Client::builder()
.api_key("dummy-key")
.http_client(http_client)
.build()
.expect("client should build");
let model = client.completion_model("anthropic/claude-haiku-4.5");
let request = model.completion_request("lookup").build();
let mut stream = model.stream(request).await.expect("stream should start");
while let Some(item) = stream.next().await {
item.expect("signed reasoning stream item");
}
let choice = stream.choice.clone().into_iter().collect::<Vec<_>>();
assert!(matches!(
choice.first(),
Some(message::AssistantContent::Reasoning(message::Reasoning { content, .. }))
if matches!(
content.first(),
Some(message::ReasoningContent::Text { text, signature: Some(signature) })
if text == "think first" && signature == "sig-live-shape"
)
));
assert!(matches!(
choice.get(1),
Some(message::AssistantContent::ToolCall(call)) if call.function.name == "lookup"
));
let messages = assistant_contents_to_messages(choice).expect("history conversion");
let Message::Assistant {
reasoning_details, ..
} = messages.first().expect("assistant message")
else {
panic!("expected assistant history message");
};
assert!(matches!(
reasoning_details.first(),
Some(ReasoningDetails::Text {
text: Some(text),
signature: Some(signature),
..
}) if text == "think first" && signature == "sig-live-shape"
));
}
#[tokio::test]
async fn id_less_encrypted_detail_does_not_replace_open_reasoning_text() {
use crate::client::CompletionClient;
use crate::completion::CompletionModel as _;
use crate::providers::internal::openai_chat_completions_compatible::test_support::sse_bytes_from_data_lines;
use crate::test_utils::MockStreamingClient;
use futures::StreamExt;
let http_client = MockStreamingClient {
sse_bytes: sse_bytes_from_data_lines([
r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning":"deep "},"finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"reasoning":"thought"},"finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{"reasoning":null,"reasoning_details":[{"type":"reasoning.encrypted","id":null,"format":"openai-responses-v1","index":0,"data":"enc_blob"}]},"finish_reason":null}]}"#,
r#"{"id":"chatcmpl-1","model":"openai/o4-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}"#,
"[DONE]",
]),
};
let client = crate::providers::openrouter::Client::builder()
.api_key("dummy-key")
.http_client(http_client)
.build()
.expect("client should build");
let model = client.completion_model("openai/o4-mini");
let request = model.completion_request("weather?").build();
let mut stream = model.stream(request).await.expect("stream should start");
while stream.next().await.is_some() {}
let choice: Vec<message::AssistantContent> = stream.choice.clone().into_iter().collect();
assert!(
choice.iter().any(|content| matches!(
content,
message::AssistantContent::Reasoning(message::Reasoning { content, .. })
if matches!(
content.first(),
Some(message::ReasoningContent::Text { text, .. }) if text == "deep thought"
)
)),
"the accumulated reasoning text must survive the encrypted detail: {choice:#?}"
);
assert!(
choice.iter().any(|content| matches!(
content,
message::AssistantContent::Reasoning(message::Reasoning { id: None, content })
if matches!(
content.first(),
Some(message::ReasoningContent::Encrypted(data)) if data == "enc_blob"
)
)),
"the encrypted blob must reach the choice as its own part: {choice:#?}"
);
}
#[test]
fn id_less_encrypted_reasoning_replays_with_a_null_wire_id() {
use crate::providers::openai::completion::OpenAICompatibleProvider as _;
let detail = json!({
"type": "reasoning.encrypted",
"id": null,
"format": null,
"index": 0,
"data": "enc_blob",
});
let (id, provider_id, content) = OpenRouterExt
.streaming_detail_reasoning(&detail)
.expect("encrypted detail should map to reasoning");
assert!(
id.is_minted(),
"id-less details key by a minted key: {id:?}"
);
assert!(
provider_id.is_none(),
"absence is None, never a fabricated id"
);
assert!(matches!(
content,
message::ReasoningContent::Encrypted(ref data) if data == "enc_blob"
));
let messages = assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(
message::Reasoning {
id: provider_id.map(|id| id.into_string()),
content: vec![content],
},
)])
.unwrap();
let Message::Assistant {
reasoning_details, ..
} = messages.first().expect("assistant message")
else {
panic!("Expected assistant message");
};
assert!(matches!(
reasoning_details.first(),
Some(ReasoningDetails::Encrypted { id: None, data, .. }) if data == "enc_blob"
));
}
#[test]
fn test_assistant_reasoning_emits_openrouter_reasoning_details() {
let reasoning = message::Reasoning {
id: Some("rs_2".to_string()),
content: vec![
message::ReasoningContent::Text {
text: "step".to_string(),
signature: Some("sig_step".to_string()),
},
message::ReasoningContent::Summary("summary".to_string()),
message::ReasoningContent::Encrypted("enc_blob".to_string()),
],
};
let messages =
assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(reasoning)])
.unwrap();
let Message::Assistant {
reasoning,
reasoning_details,
..
} = messages.first().expect("assistant message")
else {
panic!("Expected assistant message");
};
assert!(reasoning.is_none());
assert_eq!(reasoning_details.len(), 3);
assert!(matches!(
reasoning_details.first(),
Some(ReasoningDetails::Text {
id: Some(id),
text: Some(text),
signature: Some(signature),
..
}) if id == "rs_2" && text == "step" && signature == "sig_step"
));
}
#[test]
fn test_tool_call_signature_without_params_uses_wire_id_for_encrypted_detail() {
let tool_call = message::ToolCall::from_wire(
"call_wire",
message::ToolFunction {
name: "lookup".to_string(),
arguments: json!({}),
},
)
.with_signature(Some("sig-data".to_string()));
let messages =
assistant_contents_to_messages(vec![message::AssistantContent::ToolCall(tool_call)])
.unwrap();
let Message::Assistant {
reasoning_details, ..
} = messages.first().expect("assistant message")
else {
panic!("Expected assistant message");
};
assert!(matches!(
reasoning_details.first(),
Some(ReasoningDetails::Encrypted {
id: Some(id),
data,
..
}) if id == "call_wire" && data == "sig-data"
));
}
#[test]
fn test_tool_call_minimal_params_fall_back_to_wire_id() {
let tool_call = message::ToolCall::from_wire(
"call_wire",
message::ToolFunction {
name: "lookup".to_string(),
arguments: json!({}),
},
)
.with_signature(Some("sig-data".to_string()))
.with_additional_params(Some(json!({"format": "anthropic"})));
let messages =
assistant_contents_to_messages(vec![message::AssistantContent::ToolCall(tool_call)])
.unwrap();
let Message::Assistant {
reasoning_details, ..
} = messages.first().expect("assistant message")
else {
panic!("Expected assistant message");
};
assert!(matches!(
reasoning_details.first(),
Some(ReasoningDetails::Encrypted {
id: Some(id),
format,
data,
..
}) if id == "call_wire" && data == "sig-data" && format.as_deref() == Some("anthropic")
));
}
#[test]
fn test_assistant_redacted_reasoning_emits_encrypted_detail_not_text() {
let reasoning = message::Reasoning {
id: Some("rs_redacted".to_string()),
content: vec![message::ReasoningContent::Redacted {
data: "opaque-redacted-data".to_string(),
}],
};
let messages =
assistant_contents_to_messages(vec![message::AssistantContent::Reasoning(reasoning)])
.unwrap();
let Message::Assistant {
reasoning_details,
reasoning,
..
} = messages.first().expect("assistant message")
else {
panic!("Expected assistant message");
};
assert!(reasoning.is_none());
assert_eq!(reasoning_details.len(), 1);
assert!(matches!(
reasoning_details.first(),
Some(ReasoningDetails::Encrypted {
id: Some(id),
data,
..
}) if id == "rs_redacted" && data == "opaque-redacted-data"
));
}
#[test]
fn test_completion_response_reasoning_details_respects_index_ordering() {
let json = json!({
"id": "resp_ordering",
"object": "chat.completion",
"created": 1,
"model": "openrouter/test-model",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "hello",
"reasoning": null,
"reasoning_details": [
{"type":"reasoning.summary","id":"rs_order","index":1,"summary":"second"},
{"type":"reasoning.summary","id":"rs_order","index":0,"summary":"first"}
]
}
}]
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
let reasoning_blocks: Vec<_> = items
.into_iter()
.filter_map(|item| match item {
completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
_ => None,
})
.collect();
assert_eq!(reasoning_blocks.len(), 1);
assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_order"));
assert_eq!(
reasoning_blocks[0].content,
vec![
message::ReasoningContent::Summary("first".to_string()),
message::ReasoningContent::Summary("second".to_string()),
]
);
}
#[test]
fn test_user_content_from_rig_image_missing_media_type_error() {
let rig_content = message::UserContent::Image(message::Image {
data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
media_type: None, detail: None,
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("media type required"));
}
#[test]
fn test_user_content_from_rig_image_raw_bytes_error() {
let rig_content = message::UserContent::Image(message::Image {
data: DocumentSourceKind::Raw(vec![1, 2, 3]),
media_type: Some(message::ImageMediaType::PNG),
detail: None,
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("base64"));
}
#[test]
fn test_user_content_from_rig_video_url() {
let rig_content = message::UserContent::Video(message::Video {
data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
media_type: Some(message::VideoMediaType::MP4),
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::Video { video_url } => {
assert_eq!(video_url.url, "https://example.com/video.mp4");
}
_ => panic!("Expected Video variant"),
}
}
#[test]
fn test_user_content_from_rig_video_base64() {
let rig_content = message::UserContent::Video(message::Video {
data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
media_type: Some(message::VideoMediaType::MP4),
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::Video { video_url } => {
assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
}
_ => panic!("Expected Video variant"),
}
}
#[test]
fn test_user_content_from_rig_video_base64_missing_media_type_error() {
let rig_content = message::UserContent::Video(message::Video {
data: DocumentSourceKind::Base64("SGVsbG8=".to_string()),
media_type: None,
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("media type"));
}
#[test]
fn test_user_content_from_rig_video_raw_bytes_error() {
let rig_content = message::UserContent::Video(message::Video {
data: DocumentSourceKind::Raw(vec![1, 2, 3]),
media_type: Some(message::VideoMediaType::MP4),
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("base64"));
}
#[test]
fn test_user_content_from_rig_audio_base64() {
let rig_content = message::UserContent::Audio(message::Audio {
data: DocumentSourceKind::Base64("audiodata".to_string()),
media_type: Some(message::AudioMediaType::MP3),
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::Audio { input_audio } => {
assert_eq!(input_audio.data, "audiodata");
assert_eq!(input_audio.format, message::AudioMediaType::MP3);
}
_ => panic!("Expected Audio variant"),
}
}
#[test]
fn test_user_content_from_rig_audio_missing_media_type_error() {
let rig_content = message::UserContent::Audio(message::Audio {
data: DocumentSourceKind::Base64("audiodata".to_string()),
media_type: None, additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("media type required"));
}
#[test]
fn test_user_content_from_rig_audio_url_error() {
let rig_content = message::UserContent::Audio(message::Audio {
data: DocumentSourceKind::Url("https://example.com/audio.wav".to_string()),
media_type: Some(message::AudioMediaType::WAV),
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("base64"));
}
#[test]
fn test_user_content_from_rig_audio_raw_bytes_error() {
let rig_content = message::UserContent::Audio(message::Audio {
data: DocumentSourceKind::Raw(vec![1, 2, 3]),
media_type: Some(message::AudioMediaType::WAV),
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("base64"));
}
#[test]
fn test_user_content_from_rig_video_file_id_error() {
let rig_content = message::UserContent::Video(message::Video {
data: DocumentSourceKind::FileId("file-123".to_string()),
media_type: Some(message::VideoMediaType::MP4),
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string()
.contains("File IDs are not supported for video")
);
}
#[test]
fn test_user_content_from_rig_audio_file_id_error() {
let rig_content = message::UserContent::Audio(message::Audio {
data: DocumentSourceKind::FileId("file-123".to_string()),
media_type: Some(message::AudioMediaType::MP3),
additional_params: None,
});
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string()
.contains("File IDs are not supported for audio")
);
}
#[test]
fn test_video_helper_converts_to_data_uri() {
let rig_content =
message::UserContent::video("SGVsbG8=", Some(message::VideoMediaType::MP4));
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::Video { video_url } => {
assert_eq!(video_url.url, "data:video/mp4;base64,SGVsbG8=");
}
_ => panic!("Expected Video variant"),
}
}
#[test]
fn test_video_url_helper_passes_url_through() {
let rig_content = message::UserContent::video_url("https://example.com/video.mp4", None);
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::Video { video_url } => {
assert_eq!(video_url.url, "https://example.com/video.mp4");
}
_ => panic!("Expected Video variant"),
}
}
#[test]
fn test_video_raw_helper_errors() {
let rig_content =
message::UserContent::video_raw(vec![1, 2, 3], Some(message::VideoMediaType::MP4));
let result: Result<UserContent, _> = user_content_to_openai(rig_content);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("base64"));
}
#[test]
fn test_message_conversion_with_pdf() {
let rig_message = message::Message::User {
content: vec![
message::UserContent::Text(message::Text::new(
"Summarize this document".to_string(),
)),
message::UserContent::Document(message::Document {
data: DocumentSourceKind::Url("https://example.com/paper.pdf".to_string()),
media_type: Some(DocumentMediaType::PDF),
additional_params: None,
}),
],
};
let openrouter_messages: Vec<Message> = messages_from_rig_message(rig_message).unwrap();
assert_eq!(openrouter_messages.len(), 1);
match &openrouter_messages[0] {
Message::User { content, .. } => {
assert_eq!(content.len(), 2);
match content.first() {
Some(UserContent::Text { text, .. }) => {
assert_eq!(text, "Summarize this document")
}
_ => panic!("Expected Text"),
}
}
_ => panic!("Expected User message"),
}
}
#[test]
fn test_user_content_from_string() {
let content: UserContent = "Hello".into();
assert_eq!(
content,
UserContent::Text {
text: "Hello".to_string()
}
);
let content: UserContent = String::from("World").into();
assert_eq!(
content,
UserContent::Text {
text: "World".to_string()
}
);
}
#[test]
fn test_completion_response_reasoning_details_with_multiple_ids_stay_separate() {
let json = json!({
"id": "resp_multi_id",
"object": "chat.completion",
"created": 1,
"model": "openrouter/test-model",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "hello",
"reasoning": null,
"reasoning_details": [
{"type":"reasoning.summary","id":"rs_a","summary":"a1"},
{"type":"reasoning.summary","id":"rs_b","summary":"b1"},
{"type":"reasoning.summary","id":"rs_a","summary":"a2"}
]
}
}]
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
let reasoning_blocks: Vec<_> = items
.into_iter()
.filter_map(|item| match item {
completion::AssistantContent::Reasoning(reasoning) => Some(reasoning),
_ => None,
})
.collect();
assert_eq!(reasoning_blocks.len(), 2);
assert_eq!(reasoning_blocks[0].id.as_deref(), Some("rs_a"));
assert_eq!(
reasoning_blocks[0].content,
vec![
message::ReasoningContent::Summary("a1".to_string()),
message::ReasoningContent::Summary("a2".to_string()),
]
);
assert_eq!(reasoning_blocks[1].id.as_deref(), Some("rs_b"));
assert_eq!(
reasoning_blocks[1].content,
vec![message::ReasoningContent::Summary("b1".to_string())]
);
}
#[test]
fn test_user_content_audio_serialization() {
let content = UserContent::Audio {
input_audio: openai::InputAudio {
data: "SGVsbG8=".to_string(),
format: AudioMediaType::WAV,
},
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "input_audio");
assert_eq!(json["input_audio"]["data"], "SGVsbG8=");
assert_eq!(json["input_audio"]["format"], "wav");
}
#[test]
fn test_user_content_audio_deserialization() {
let json = json!({
"type": "input_audio",
"input_audio": {
"data": "SGVsbG8=",
"format": "wav"
}
});
let content: UserContent = serde_json::from_value(json).unwrap();
match content {
UserContent::Audio { input_audio } => {
assert_eq!(input_audio.data, "SGVsbG8=");
assert_eq!(input_audio.format, AudioMediaType::WAV);
}
_ => panic!("Expected Audio variant"),
}
}
#[test]
fn test_message_user_with_audio_serialization() {
let msg = Message::User {
content: vec![
UserContent::Text {
text: "Transcribe this audio:".to_string(),
},
UserContent::Audio {
input_audio: openai::InputAudio {
data: "SGVsbG8=".to_string(),
format: AudioMediaType::MP3,
},
},
],
name: None,
};
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["role"], "user");
let content = json["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "input_audio");
assert_eq!(content[1]["input_audio"]["data"], "SGVsbG8=");
assert_eq!(content[1]["input_audio"]["format"], "mp3");
}
#[test]
fn test_user_content_video_url_serialization() {
let content = UserContent::Video {
video_url: VideoUrl {
url: "https://example.com/video.mp4".to_string(),
},
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "video_url");
assert_eq!(json["video_url"]["url"], "https://example.com/video.mp4");
}
#[test]
fn test_user_content_video_base64_serialization() {
let content = UserContent::Video {
video_url: VideoUrl {
url: format!(
"data:{};base64,SGVsbG8=",
VideoMediaType::MP4.to_mime_type()
),
},
};
let json = serde_json::to_value(&content).unwrap();
assert_eq!(json["type"], "video_url");
assert_eq!(json["video_url"]["url"], "data:video/mp4;base64,SGVsbG8=");
}
#[test]
fn test_user_content_video_url_deserialization() {
let json = json!({
"type": "video_url",
"video_url": {
"url": "https://example.com/video.mp4"
}
});
let content: UserContent = serde_json::from_value(json).unwrap();
match content {
UserContent::Video { video_url } => {
assert_eq!(video_url.url, "https://example.com/video.mp4");
}
_ => panic!("Expected Video variant"),
}
}
#[test]
fn test_message_user_with_video_serialization() {
let msg = Message::User {
content: vec![
UserContent::Text {
text: "Describe this video:".to_string(),
},
UserContent::Video {
video_url: VideoUrl {
url: "https://example.com/video.mp4".to_string(),
},
},
],
name: None,
};
let json = serde_json::to_value(&msg).unwrap();
assert_eq!(json["role"], "user");
let content = json["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[1]["type"], "video_url");
assert_eq!(
content[1]["video_url"]["url"],
"https://example.com/video.mp4"
);
}
#[test]
fn test_user_content_video_url_no_media_type_needed() {
let rig_content = message::UserContent::Video(message::Video {
data: DocumentSourceKind::Url("https://example.com/video.mp4".to_string()),
media_type: None,
additional_params: None,
});
let openrouter_content: UserContent = user_content_to_openai(rig_content).unwrap();
match openrouter_content {
UserContent::Video { video_url } => {
assert_eq!(video_url.url, "https://example.com/video.mp4");
}
_ => panic!("Expected Video variant"),
}
}
fn prompt_caching_completion_request() -> CompletionRequest {
CompletionRequest {
model: None,
preamble: Some("You are a helpful assistant.".to_string()),
chat_history: vec![crate::message::Message::user("Hello")],
documents: vec![],
tools: vec![],
temperature: None,
max_tokens: None,
tool_choice: None,
additional_params: None,
output_schema: None,
record_telemetry_content: false,
}
}
#[test]
fn test_final_request_body_applies_prompt_caching_to_converted_completion_request() {
let request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
model: "anthropic/claude-3.5-sonnet",
request: prompt_caching_completion_request(),
strict_tools: false,
})
.expect("request conversion should succeed");
let body = final_request_body(&request, true).expect("request body should serialize");
let system_block = &body["messages"][0]["content"][0];
assert_eq!(system_block["type"], "text");
assert_eq!(system_block["text"], "You are a helpful assistant.");
assert_eq!(system_block["cache_control"]["type"], "ephemeral");
let body = final_request_body(&request, false).expect("request body should serialize");
assert!(
body["messages"][0]["content"][0]
.get("cache_control")
.is_none(),
"prompt caching should be opt-in"
);
}
#[test]
fn test_final_request_body_preserves_stream_flag_when_prompt_caching_enabled() {
let mut request = OpenrouterCompletionRequest::try_from(OpenRouterRequestParams {
model: "anthropic/claude-3.5-sonnet",
request: prompt_caching_completion_request(),
strict_tools: false,
})
.expect("request conversion should succeed");
request.additional_params = Some(json!({ "stream": true }));
let body = final_request_body(&request, true).expect("request body should serialize");
assert_eq!(body["stream"], true);
assert_eq!(
body["messages"][0]["content"][0]["cache_control"]["type"],
"ephemeral"
);
}
#[test]
fn test_apply_prompt_caching_string_system_message() {
let mut body = json!({
"model": "anthropic/claude-3.5-sonnet",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello"}
]
});
apply_prompt_caching(&mut body);
let system_content = &body["messages"][0]["content"];
assert!(
system_content.is_array(),
"system content should be an array after caching"
);
let block = &system_content[0];
assert_eq!(block["type"], "text");
assert_eq!(block["text"], "You are a helpful assistant.");
assert_eq!(block["cache_control"]["type"], "ephemeral");
assert_eq!(body["messages"][1]["content"], "Hello");
}
#[test]
fn test_apply_prompt_caching_array_system_message_marks_last_block() {
let mut body = json!({
"model": "anthropic/claude-3.5-sonnet",
"messages": [
{
"role": "system",
"content": [
{"type": "text", "text": "Part 1. "},
{"type": "text", "text": "Part 2."}
]
}
]
});
apply_prompt_caching(&mut body);
let system_content = &body["messages"][0]["content"];
assert!(system_content.is_array());
assert_eq!(system_content.as_array().unwrap().len(), 2);
assert_eq!(system_content[0]["text"], "Part 1. ");
assert!(system_content[0].get("cache_control").is_none());
assert_eq!(system_content[1]["text"], "Part 2.");
assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
}
#[test]
fn test_apply_prompt_caching_preserves_non_text_blocks() {
let mut body = json!({
"model": "anthropic/claude-3.5-sonnet",
"messages": [
{
"role": "system",
"content": [
{"type": "image", "source": {"type": "url", "url": "https://example.com/img.png"}},
{"type": "text", "text": "Describe the image."}
]
}
]
});
apply_prompt_caching(&mut body);
let system_content = &body["messages"][0]["content"];
assert_eq!(system_content.as_array().unwrap().len(), 2);
assert_eq!(system_content[0]["type"], "image");
assert!(system_content[0].get("cache_control").is_none());
assert_eq!(system_content[1]["type"], "text");
assert_eq!(system_content[1]["cache_control"]["type"], "ephemeral");
}
#[test]
fn test_apply_prompt_caching_no_system_message_is_noop() {
let mut body = json!({
"model": "openai/gpt-4o",
"messages": [
{"role": "user", "content": "Hello"}
]
});
let body_before = body.clone();
apply_prompt_caching(&mut body);
assert_eq!(
body, body_before,
"body should be unchanged when no system message exists"
);
}
#[test]
fn test_completion_response_extracts_generated_images() {
let json = json!({
"id": "resp_img",
"object": "chat.completion",
"created": 1,
"model": "google/gemini-flash-image-preview",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "Here is your image.",
"images": [
{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo="}}
]
}
}]
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
assert_eq!(items.len(), 2);
assert!(items.iter().any(|item| matches!(
item,
completion::AssistantContent::Text(t) if t.text == "Here is your image."
)));
assert!(items.iter().any(|item| matches!(
item,
completion::AssistantContent::Image(message::Image {
data: message::DocumentSourceKind::Base64(b64),
media_type: Some(message::ImageMediaType::PNG),
additional_params: Some(_),
..
}) if b64 == "iVBORw0KGgo="
)));
assert!(
items.iter().any(|item| matches!(
item,
completion::AssistantContent::Image(image)
if is_openrouter_response_image(image)
)),
"generated images should be marked as OpenRouter response-only artifacts"
);
}
#[test]
fn test_completion_response_extracts_generated_images_url() {
let json = json!({
"id": "resp_img_url",
"object": "chat.completion",
"created": 1,
"model": "google/gemini-flash-image-preview",
"choices": [{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "Here is your image.",
"images": [
{"type":"image_url","image_url":{"url":"https://example.com/generated.png"}}
]
}
}]
});
let response: CompletionResponse = serde_json::from_value(json).unwrap();
let converted = response.normalize(PROVIDER_NAME).unwrap();
let items: Vec<completion::AssistantContent> = converted.choice.into_iter().collect();
assert_eq!(items.len(), 2);
assert!(items.iter().any(|item| matches!(
item,
completion::AssistantContent::Image(message::Image {
data: message::DocumentSourceKind::Url(url),
media_type: None,
additional_params: Some(_),
..
}) if url == "https://example.com/generated.png"
)));
assert!(
items.iter().any(|item| matches!(
item,
completion::AssistantContent::Image(image)
if is_openrouter_response_image(image)
)),
"generated URL images should be marked as OpenRouter response-only artifacts"
);
}
#[test]
fn test_generated_images_do_not_break_assistant_history_conversion() {
let generated_image = response_image_to_assistant_content(&ResponseImage {
image_url: ImageUrl {
url: "data:image/png;base64,abc".to_string(),
detail: None,
},
});
let content = vec![
completion::AssistantContent::text("Here is your image."),
generated_image,
];
let messages = assistant_contents_to_messages(content).unwrap();
assert_eq!(messages.len(), 1);
assert!(matches!(
&messages[0],
Message::Assistant { content, .. }
if content == &vec![openai::AssistantContent::Text {
text: "Here is your image.".to_string()
}]
));
}
#[test]
fn test_image_only_assistant_history_is_omitted_for_openrouter() {
let generated_image = response_image_to_assistant_content(&ResponseImage {
image_url: ImageUrl {
url: "data:image/png;base64,abc".to_string(),
detail: None,
},
});
let messages = assistant_contents_to_messages(vec![generated_image]).unwrap();
assert!(
messages.is_empty(),
"response-only generated image turns should not be replayed as assistant content"
);
}
#[test]
fn test_unmarked_assistant_image_history_errors_for_openrouter() {
let image = completion::AssistantContent::image_base64(
"abc",
Some(message::ImageMediaType::PNG),
None,
);
let err = assistant_contents_to_messages(vec![image]).unwrap_err();
match err {
message::MessageError::ConversionError(message) => assert!(
message.contains("OpenRouter does not support assistant image content"),
"unexpected error: {message}"
),
}
}
#[test]
fn test_mixed_text_and_generated_image_replays_text_only_for_openrouter() {
let generated_image = response_image_to_assistant_content(&ResponseImage {
image_url: ImageUrl {
url: "https://example.com/generated.png".to_string(),
detail: None,
},
});
let messages = assistant_contents_to_messages(vec![
completion::AssistantContent::text("Keep this text."),
generated_image,
])
.unwrap();
let serialized = serde_json::to_value(&messages).unwrap();
assert_eq!(
serialized,
json!([{
"role": "assistant",
"content": [{"type": "text", "text": "Keep this text."}]
}])
);
}
#[test]
fn test_assistant_images_not_serialized_in_request() {
let msg = Message::Assistant {
content: vec!["Hello".to_string().into()],
refusal: None,
audio: None,
name: None,
tool_calls: vec![],
reasoning: None,
reasoning_details: vec![],
images: vec![ResponseImage {
image_url: ImageUrl {
url: "data:image/png;base64,abc".to_string(),
detail: None,
},
}],
};
let serialized = serde_json::to_value(&msg).unwrap();
assert!(
serialized.get("images").is_none(),
"images field must not appear in serialized assistant message"
);
}
fn refusal_response(message: serde_json::Value) -> CompletionResponse {
serde_json::from_value(json!({
"id": "gen-refusal",
"object": "chat.completion",
"created": 1,
"model": "openai/gpt-4o",
"choices": [{ "index": 0, "message": message, "finish_reason": "stop" }],
}))
.unwrap()
}
#[test]
fn raw_completion_response_retains_routing_metadata() {
let response: CompletionResponse = serde_json::from_value(json!({
"id": "gen-routing",
"object": "chat.completion",
"created": 1,
"model": "openai/gpt-4o-mini",
"provider": "OpenAI",
"service_tier": "default",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "ok"},
"finish_reason": "stop"
}]
}))
.expect("live OpenRouter routing metadata should deserialize");
assert_eq!(response.provider.as_deref(), Some("OpenAI"));
assert_eq!(response.service_tier.as_deref(), Some("default"));
}
fn text_parts(response: &completion::CompletionResponse) -> Vec<String> {
response
.choice
.iter()
.filter_map(|part| match part {
completion::AssistantContent::Text(text) => Some(text.text.clone()),
_ => None,
})
.collect()
}
#[test]
fn refusal_fallback_surfaces_a_null_content_refusal() {
let response = refusal_response(json!({
"role": "assistant",
"content": null,
"refusal": "I'm very sorry, but I can't assist with that request.",
}));
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(
text_parts(&converted),
vec!["I'm very sorry, but I can't assist with that request."]
);
}
#[test]
fn refusal_fallback_surfaces_a_missing_content_refusal() {
let response = refusal_response(json!({
"role": "assistant",
"refusal": "No.",
}));
assert_eq!(
text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
vec!["No."]
);
}
#[test]
fn refusal_fallback_surfaces_a_refusal_beside_empty_content() {
let response = refusal_response(json!({
"role": "assistant",
"content": "",
"refusal": "No.",
}));
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(
text_parts(&converted),
vec!["".to_owned(), "No.".to_owned()]
);
}
#[test]
fn refusal_fallback_defers_to_non_empty_content() {
let response = refusal_response(json!({
"role": "assistant",
"content": "Here is the answer.",
"refusal": "I'm sorry.",
}));
assert_eq!(
text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
vec!["Here is the answer."]
);
}
#[test]
fn refusal_fallback_ignores_an_empty_refusal() {
let response = refusal_response(json!({
"role": "assistant",
"content": null,
"refusal": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "ping", "arguments": "{}" }
}],
}));
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert!(text_parts(&converted).is_empty(), "{:?}", converted.choice);
assert_eq!(converted.choice.len(), 1);
}
#[test]
fn refusal_fallback_leaves_a_tool_call_turn_alone() {
let response = refusal_response(json!({
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "ping", "arguments": "{}" }
}],
}));
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(converted.choice.len(), 1);
assert!(matches!(
converted.choice.first(),
Some(completion::AssistantContent::ToolCall(_))
));
}
#[test]
fn refusal_fallback_coexists_with_tool_calls() {
let response = refusal_response(json!({
"role": "assistant",
"content": null,
"refusal": "I can't help with that.",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "ping", "arguments": "{}" }
}],
}));
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(text_parts(&converted), vec!["I can't help with that."]);
assert!(
converted
.choice
.iter()
.any(|part| matches!(part, completion::AssistantContent::ToolCall(_)))
);
}
#[test]
fn refusal_fallback_applies_beside_reasoning_details() {
let response = refusal_response(json!({
"role": "assistant",
"content": null,
"refusal": "I can't help with that.",
"reasoning_details": [
{ "type": "reasoning.summary", "id": "rs_1", "format": "openai-responses-v1",
"index": 0, "summary": "considered" }
],
}));
let converted = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(text_parts(&converted), vec!["I can't help with that."]);
assert!(
converted
.choice
.iter()
.any(|part| matches!(part, completion::AssistantContent::Reasoning(_)))
);
}
#[test]
fn refusal_fallback_does_not_double_up_with_a_refusal_content_part() {
let response = refusal_response(json!({
"role": "assistant",
"content": [{ "type": "refusal", "refusal": "I can't help with that." }],
"refusal": "I can't help with that.",
}));
assert_eq!(
text_parts(&response.normalize(PROVIDER_NAME).unwrap()),
vec!["I can't help with that."]
);
}
#[test]
fn refusal_fallback_keeps_raw_and_normalized_text_in_step() {
let response = refusal_response(json!({
"role": "assistant",
"content": null,
"refusal": "I'm sorry, but I can't help with that.",
}));
let raw_text = response.get_text_response().unwrap();
let normalized = response.normalize(PROVIDER_NAME).unwrap();
assert_eq!(text_parts(&normalized), vec![raw_text]);
}
}