use bytes::Bytes;
use futures::StreamExt;
use reqwest::Client;
use reqwest::redirect::Policy;
use serde::Deserialize;
use std::time::Duration;
use tracing::warn;
use crate::error::Error;
use crate::llm::LlmProvider;
use crate::llm::anthropic::SseParser;
use crate::llm::types::{
CompletionRequest, CompletionResponse, ContentBlock, ReasoningEffort, Role, StopReason,
TokenUsage, ToolChoice, ToolDefinition,
};
const API_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
fn build_secure_client() -> Result<Client, Error> {
Client::builder()
.redirect(Policy::none())
.https_only(true)
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(120))
.build()
.map_err(Error::from)
}
pub struct OpenRouterProvider {
client: Client,
api_key: String,
model: String,
}
impl OpenRouterProvider {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
client: build_secure_client()
.expect("failed to build hardened HTTPS client for OpenRouterProvider"),
api_key: api_key.into(),
model: model.into(),
}
}
}
impl LlmProvider for OpenRouterProvider {
fn model_name(&self) -> Option<&str> {
Some(&self.model)
}
async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, Error> {
let body = build_openai_request(&self.model, &request)?;
let response = self
.client
.post(API_URL)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
return Err(super::api_error_from_response(response).await);
}
let api_response: OpenAiResponse = response.json().await?;
into_completion_response(api_response)
}
async fn stream_complete(
&self,
request: CompletionRequest,
on_text: &crate::llm::OnText,
) -> Result<CompletionResponse, Error> {
let mut body = build_openai_request(&self.model, &request)?;
body["stream"] = serde_json::json!(true);
body["stream_options"] = serde_json::json!({"include_usage": true});
let response = self
.client
.post(API_URL)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
return Err(super::api_error_from_response(response).await);
}
parse_openai_stream(response.bytes_stream(), on_text).await
}
}
pub(crate) fn build_openai_request(
model: &str,
request: &CompletionRequest,
) -> Result<serde_json::Value, Error> {
let mut messages = Vec::new();
if !request.system.is_empty() {
messages.push(serde_json::json!({
"role": "system",
"content": request.system,
}));
}
for msg in &request.messages {
match msg.role {
Role::User => {
let has_media = msg
.content
.iter()
.any(|b| matches!(b, ContentBlock::Image { .. } | ContentBlock::Audio { .. }));
let mut text_parts = Vec::new();
let mut media_parts = Vec::new();
for block in &msg.content {
match block {
ContentBlock::Text { text } => {
text_parts.push(text.as_str());
}
ContentBlock::Image { media_type, data } => {
media_parts.push(serde_json::json!({
"type": "image_url",
"image_url": {
"url": format!("data:{media_type};base64,{data}")
}
}));
}
ContentBlock::Audio { format, data } => {
media_parts.push(serde_json::json!({
"type": "input_audio",
"input_audio": {
"data": data,
"format": format,
}
}));
}
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => {
let content = if *is_error {
format!("[ERROR] {content}")
} else {
content.clone()
};
messages.push(serde_json::json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
}));
}
_ => {}
}
}
if has_media {
let mut content_parts: Vec<serde_json::Value> = Vec::new();
if !text_parts.is_empty() {
content_parts.push(serde_json::json!({
"type": "text",
"text": text_parts.join("\n\n"),
}));
}
content_parts.extend(media_parts);
if !content_parts.is_empty() {
messages.push(serde_json::json!({
"role": "user",
"content": content_parts,
}));
}
} else if !text_parts.is_empty() {
messages.push(serde_json::json!({
"role": "user",
"content": text_parts.join("\n\n"),
}));
} else if !msg
.content
.iter()
.any(|b| matches!(b, ContentBlock::ToolResult { .. }))
{
messages.push(serde_json::json!({
"role": "user",
"content": "",
}));
}
}
Role::Assistant => {
let text: String = msg
.content
.iter()
.filter_map(|b| match b {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
let tool_calls: Vec<serde_json::Value> = msg
.content
.iter()
.filter_map(|b| match b {
ContentBlock::ToolUse { id, name, input } => Some(serde_json::json!({
"id": id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(input)
.expect("serde_json::Value serialization is infallible"),
}
})),
_ => None,
})
.collect();
let mut msg_json = serde_json::json!({
"role": "assistant",
});
if !text.is_empty() {
msg_json["content"] = serde_json::Value::String(text);
} else {
msg_json["content"] = serde_json::Value::Null;
}
if !tool_calls.is_empty() {
msg_json["tool_calls"] = serde_json::Value::Array(tool_calls);
}
messages.push(msg_json);
}
}
}
let mut body = serde_json::json!({
"model": model,
"messages": messages,
"max_tokens": request.max_tokens,
});
if !request.tools.is_empty() {
let tools: Vec<serde_json::Value> = request.tools.iter().map(tool_to_openai).collect();
body["tools"] = serde_json::Value::Array(tools);
}
if let Some(ref tc) = request.tool_choice {
body["tool_choice"] = tool_choice_to_openai(tc);
}
if let Some(effort) = request.reasoning_effort {
let effort_str = match effort {
ReasoningEffort::High => "high",
ReasoningEffort::Medium => "medium",
ReasoningEffort::Low => "low",
ReasoningEffort::None => "none",
};
body["reasoning"] = serde_json::json!({"effort": effort_str});
}
Ok(body)
}
fn tool_choice_to_openai(tc: &ToolChoice) -> serde_json::Value {
match tc {
ToolChoice::Auto => serde_json::json!("auto"),
ToolChoice::Any => serde_json::json!("required"),
ToolChoice::Tool { name } => serde_json::json!({
"type": "function",
"function": {"name": name}
}),
}
}
fn tool_to_openai(tool: &ToolDefinition) -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
}
})
}
#[derive(Deserialize)]
pub(crate) struct OpenAiResponse {
choices: Vec<OpenAiChoice>,
#[serde(default)]
usage: Option<OpenAiUsage>,
}
#[derive(Deserialize)]
struct OpenAiChoice {
message: OpenAiMessage,
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct OpenAiMessage {
#[serde(default)]
content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<OpenAiToolCall>>,
}
#[derive(Deserialize)]
struct OpenAiToolCall {
id: String,
function: OpenAiFunction,
}
#[derive(Deserialize)]
struct OpenAiFunction {
name: String,
arguments: String,
}
#[derive(Deserialize, Default)]
struct OpenAiUsage {
prompt_tokens: u32,
completion_tokens: u32,
#[serde(default)]
cache_creation_input_tokens: u32,
#[serde(default)]
cache_read_input_tokens: u32,
#[serde(default)]
reasoning_tokens: u32,
}
pub(crate) fn into_completion_response(api: OpenAiResponse) -> Result<CompletionResponse, Error> {
let choice = api.choices.into_iter().next().ok_or_else(|| Error::Api {
status: 502,
message: "empty choices array in response".into(),
})?;
let mut content = Vec::new();
if let Some(text) = choice.message.content
&& !text.is_empty()
{
content.push(ContentBlock::Text { text });
}
if let Some(tool_calls) = choice.message.tool_calls {
for tc in tool_calls {
let input: serde_json::Value = if tc.function.arguments.is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&tc.function.arguments).unwrap_or_else(|e| {
tracing::warn!(
tool = %tc.function.name,
error = %e,
"malformed tool arguments JSON, defaulting to empty object"
);
serde_json::json!({})
})
};
content.push(ContentBlock::ToolUse {
id: tc.id,
name: tc.function.name,
input,
});
}
}
let has_tool_calls = content
.iter()
.any(|c| matches!(c, ContentBlock::ToolUse { .. }));
let stop_reason = match choice.finish_reason.as_deref() {
Some("tool_calls") => StopReason::ToolUse,
Some("stop") if has_tool_calls => StopReason::ToolUse,
Some("stop") => StopReason::EndTurn,
Some("length") => StopReason::MaxTokens,
Some(other) => {
warn!(
finish_reason = other,
"unknown finish_reason, treating as EndTurn"
);
StopReason::EndTurn
}
None => StopReason::EndTurn,
};
let usage = api.usage.map_or(TokenUsage::default(), |u| TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
cache_creation_input_tokens: u.cache_creation_input_tokens,
cache_read_input_tokens: u.cache_read_input_tokens,
reasoning_tokens: u.reasoning_tokens,
});
Ok(CompletionResponse {
content,
stop_reason,
usage,
model: None,
})
}
#[derive(Deserialize, Default)]
struct StreamingChunk {
#[serde(default)]
choices: Vec<StreamingChoice>,
#[serde(default)]
usage: Option<OpenAiUsage>,
}
#[derive(Deserialize)]
struct StreamingChoice {
#[serde(default)]
delta: StreamingDelta,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize, Default)]
struct StreamingDelta {
#[serde(default)]
content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<StreamingToolCallDelta>>,
}
#[derive(Deserialize)]
struct StreamingToolCallDelta {
#[serde(default)]
index: usize,
#[serde(default)]
id: Option<String>,
#[serde(default)]
function: Option<StreamingFunctionDelta>,
}
#[derive(Deserialize)]
struct StreamingFunctionDelta {
#[serde(default)]
name: Option<String>,
#[serde(default)]
arguments: Option<String>,
}
#[derive(Default)]
struct AccumulatedToolCall {
id: String,
name: String,
arguments: String,
}
pub(crate) async fn parse_openai_stream<S>(
stream: S,
on_text: &super::OnText,
) -> Result<CompletionResponse, Error>
where
S: futures::Stream<Item = Result<Bytes, reqwest::Error>> + Unpin,
{
let mut parser = SseParser::new();
let mut utf8_buf: Vec<u8> = Vec::new();
let mut text = String::new();
let mut tool_calls: Vec<AccumulatedToolCall> = Vec::new();
let mut finish_reason: Option<String> = None;
let mut usage = TokenUsage::default();
tokio::pin!(stream);
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(Error::Http)?;
utf8_buf.extend_from_slice(&chunk);
let valid_len = match std::str::from_utf8(&utf8_buf) {
Ok(_) => utf8_buf.len(),
Err(e) => e.valid_up_to(),
};
if valid_len > 0 {
let s = std::str::from_utf8(&utf8_buf[..valid_len])
.expect("valid_up_to guarantees valid UTF-8");
for event in parser.feed(s) {
process_openai_event(
&event.data,
on_text,
&mut text,
&mut tool_calls,
&mut finish_reason,
&mut usage,
);
}
}
utf8_buf.drain(..valid_len);
}
if !utf8_buf.is_empty()
&& let Ok(s) = std::str::from_utf8(&utf8_buf)
{
for event in parser.feed(s) {
process_openai_event(
&event.data,
on_text,
&mut text,
&mut tool_calls,
&mut finish_reason,
&mut usage,
);
}
}
for event in parser.flush() {
process_openai_event(
&event.data,
on_text,
&mut text,
&mut tool_calls,
&mut finish_reason,
&mut usage,
);
}
let mut content = Vec::new();
if !text.is_empty() {
content.push(ContentBlock::Text { text });
}
for tc in tool_calls {
let input = if tc.arguments.is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(&tc.arguments).unwrap_or_else(|e| {
warn!(tool = %tc.name, error = %e, "malformed streaming tool arguments");
serde_json::json!({})
})
};
content.push(ContentBlock::ToolUse {
id: tc.id,
name: tc.name,
input,
});
}
if content.is_empty() && finish_reason.is_none() {
return Err(Error::Api {
status: 502,
message: "empty choices in all streaming chunks".into(),
});
}
let has_tool_calls = content
.iter()
.any(|c| matches!(c, ContentBlock::ToolUse { .. }));
let stop_reason = match finish_reason.as_deref() {
Some("tool_calls") => StopReason::ToolUse,
Some("stop") if has_tool_calls => StopReason::ToolUse,
Some("stop") => StopReason::EndTurn,
Some("length") => StopReason::MaxTokens,
Some(other) => {
warn!(
finish_reason = other,
"unknown finish_reason in stream, treating as EndTurn"
);
StopReason::EndTurn
}
None => StopReason::EndTurn,
};
Ok(CompletionResponse {
content,
stop_reason,
usage,
model: None,
})
}
fn process_openai_event(
data: &str,
on_text: &super::OnText,
text: &mut String,
tool_calls: &mut Vec<AccumulatedToolCall>,
finish_reason: &mut Option<String>,
usage: &mut TokenUsage,
) {
if data == "[DONE]" {
return;
}
let chunk: StreamingChunk = match serde_json::from_str(data) {
Ok(c) => c,
Err(e) => {
warn!(error = %e, "failed to parse streaming chunk, skipping");
return;
}
};
if let Some(choice) = chunk.choices.first() {
if let Some(ref content) = choice.delta.content {
if text.len().saturating_add(content.len()) <= super::STREAM_MAX_TEXT_BYTES {
text.push_str(content);
on_text(content);
} else if text.len() < super::STREAM_MAX_TEXT_BYTES {
let remaining = super::STREAM_MAX_TEXT_BYTES - text.len();
let take = std::cmp::min(remaining, content.len());
let boundary = crate::tool::builtins::floor_char_boundary(content, take);
let safe = &content[..boundary];
text.push_str(safe);
on_text(safe);
tracing::warn!(
text_len = text.len(),
limit = super::STREAM_MAX_TEXT_BYTES,
"OpenAI-format streaming text exceeded cap; truncated"
);
}
}
if let Some(ref tcs) = choice.delta.tool_calls {
for tc_delta in tcs {
if tc_delta.index >= super::STREAM_MAX_TOOL_CALLS {
tracing::warn!(
index = tc_delta.index,
limit = super::STREAM_MAX_TOOL_CALLS,
"OpenAI-format tool_call index exceeds cap; dropping delta"
);
continue;
}
while tool_calls.len() <= tc_delta.index {
tool_calls.push(AccumulatedToolCall::default());
}
let tc = &mut tool_calls[tc_delta.index];
if let Some(ref id) = tc_delta.id {
tc.id.clone_from(id);
}
if let Some(ref func) = tc_delta.function {
if let Some(ref name) = func.name {
tc.name.clone_from(name);
}
if let Some(ref args) = func.arguments
&& tc.arguments.len().saturating_add(args.len())
<= super::STREAM_MAX_TOOL_ARGS_BYTES
{
tc.arguments.push_str(args);
}
}
}
}
if choice.finish_reason.is_some() {
*finish_reason = choice.finish_reason.clone();
}
}
if let Some(ref u) = chunk.usage {
*usage = TokenUsage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
cache_creation_input_tokens: u.cache_creation_input_tokens,
cache_read_input_tokens: u.cache_read_input_tokens,
reasoning_tokens: u.reasoning_tokens,
};
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::types::Message;
use serde_json::json;
#[test]
fn build_request_minimal() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hello")],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("anthropic/claude-sonnet-4", &request).unwrap();
assert_eq!(body["model"], "anthropic/claude-sonnet-4");
assert_eq!(body["max_tokens"], 1024);
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[0]["content"], "hello");
}
#[test]
fn build_request_with_system() {
let request = CompletionRequest {
system: "You are helpful.".into(),
messages: vec![Message::user("hi")],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "system");
assert_eq!(messages[0]["content"], "You are helpful.");
assert_eq!(messages[1]["role"], "user");
}
#[test]
fn build_request_with_tools() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("search")],
tools: vec![ToolDefinition {
name: "search".into(),
description: "Search the web".into(),
input_schema: json!({"type": "object", "properties": {"q": {"type": "string"}}}),
}],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let tools = body["tools"].as_array().unwrap();
assert_eq!(tools.len(), 1);
assert_eq!(tools[0]["type"], "function");
assert_eq!(tools[0]["function"]["name"], "search");
}
#[test]
fn build_request_assistant_with_tool_calls() {
let request = CompletionRequest {
system: String::new(),
messages: vec![
Message::user("search for rust"),
Message {
role: Role::Assistant,
content: vec![
ContentBlock::Text {
text: "Let me search.".into(),
},
ContentBlock::ToolUse {
id: "call-1".into(),
name: "search".into(),
input: json!({"q": "rust"}),
},
],
},
],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
let assistant_msg = &messages[1];
assert_eq!(assistant_msg["role"], "assistant");
assert_eq!(assistant_msg["content"], "Let me search.");
assert_eq!(assistant_msg["tool_calls"][0]["id"], "call-1");
assert_eq!(assistant_msg["tool_calls"][0]["function"]["name"], "search");
}
#[test]
fn build_request_tool_results() {
use crate::llm::types::ToolResult;
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::tool_results(vec![
ToolResult::success("call-1", "found it"),
ToolResult::error("call-2", "not found"),
])],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 2); assert_eq!(messages[0]["role"], "tool");
assert_eq!(messages[0]["tool_call_id"], "call-1");
assert_eq!(messages[0]["content"], "found it");
assert_eq!(messages[1]["role"], "tool");
assert_eq!(messages[1]["tool_call_id"], "call-2");
assert_eq!(messages[1]["content"], "[ERROR] not found");
}
#[test]
fn parse_text_response() {
let api = OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: Some("Hello!".into()),
tool_calls: None,
},
finish_reason: Some("stop".into()),
}],
usage: Some(OpenAiUsage {
prompt_tokens: 10,
completion_tokens: 5,
..Default::default()
}),
};
let response = into_completion_response(api).unwrap();
assert_eq!(response.text(), "Hello!");
assert_eq!(response.stop_reason, StopReason::EndTurn);
assert_eq!(response.usage.input_tokens, 10);
assert_eq!(response.usage.output_tokens, 5);
}
#[test]
fn parse_tool_call_response() {
let api = OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: Some("Let me search.".into()),
tool_calls: Some(vec![OpenAiToolCall {
id: "call_abc".into(),
function: OpenAiFunction {
name: "search".into(),
arguments: r#"{"q":"rust"}"#.into(),
},
}]),
},
finish_reason: Some("tool_calls".into()),
}],
usage: Some(OpenAiUsage {
prompt_tokens: 20,
completion_tokens: 10,
..Default::default()
}),
};
let response = into_completion_response(api).unwrap();
assert_eq!(response.stop_reason, StopReason::ToolUse);
assert_eq!(response.text(), "Let me search.");
let calls = response.tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_abc");
assert_eq!(calls[0].name, "search");
assert_eq!(calls[0].input["q"], "rust");
}
#[test]
fn parse_max_tokens_response() {
let api = OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: Some("truncated...".into()),
tool_calls: None,
},
finish_reason: Some("length".into()),
}],
usage: None,
};
let response = into_completion_response(api).unwrap();
assert_eq!(response.stop_reason, StopReason::MaxTokens);
}
#[test]
fn parse_empty_choices_errors() {
let api = OpenAiResponse {
choices: vec![],
usage: None,
};
let err = into_completion_response(api).unwrap_err();
assert!(err.to_string().contains("empty choices"));
match &err {
Error::Api { status, .. } => assert_eq!(*status, 502),
other => panic!("expected Error::Api, got: {other:?}"),
}
}
#[test]
fn parse_parallel_tool_calls() {
let api = OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: None,
tool_calls: Some(vec![
OpenAiToolCall {
id: "call_1".into(),
function: OpenAiFunction {
name: "search".into(),
arguments: r#"{"q":"a"}"#.into(),
},
},
OpenAiToolCall {
id: "call_2".into(),
function: OpenAiFunction {
name: "read".into(),
arguments: r#"{"path":"/tmp"}"#.into(),
},
},
]),
},
finish_reason: Some("tool_calls".into()),
}],
usage: None,
};
let response = into_completion_response(api).unwrap();
let calls = response.tool_calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "search");
assert_eq!(calls[1].name, "read");
}
#[test]
fn parse_stop_with_tool_calls_normalizes_to_tool_use() {
let api = OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: None,
tool_calls: Some(vec![OpenAiToolCall {
id: "call_1".into(),
function: OpenAiFunction {
name: "search".into(),
arguments: "{}".into(),
},
}]),
},
finish_reason: Some("stop".into()), }],
usage: None,
};
let response = into_completion_response(api).unwrap();
assert_eq!(response.stop_reason, StopReason::ToolUse); assert_eq!(response.tool_calls().len(), 1);
}
#[test]
fn build_request_multi_text_blocks_concatenated() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message {
role: Role::User,
content: vec![
ContentBlock::Text {
text: "First paragraph.".into(),
},
ContentBlock::Text {
text: "Second paragraph.".into(),
},
],
}],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0]["role"], "user");
assert_eq!(
messages[0]["content"],
"First paragraph.\n\nSecond paragraph."
);
}
#[test]
fn build_request_mixed_user_message_tool_results_before_text() {
let request = CompletionRequest {
system: String::new(),
messages: vec![
Message::user("search for rust"),
Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
id: "call-1".into(),
name: "search".into(),
input: json!({"q": "rust"}),
}],
},
Message {
role: Role::User,
content: vec![
ContentBlock::Text {
text: "Here are the results:".into(),
},
ContentBlock::ToolResult {
tool_use_id: "call-1".into(),
content: "found it".into(),
is_error: false,
},
],
},
],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 4);
assert_eq!(messages[0]["role"], "user");
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[2]["role"], "tool");
assert_eq!(messages[2]["tool_call_id"], "call-1");
assert_eq!(messages[3]["role"], "user");
assert_eq!(messages[3]["content"], "Here are the results:");
}
#[test]
fn build_request_user_with_image_uses_array_content() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message {
role: Role::User,
content: vec![
ContentBlock::Text {
text: "What is this?".into(),
},
ContentBlock::Image {
media_type: "image/jpeg".into(),
data: "base64data".into(),
},
],
}],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "What is this?");
assert_eq!(content[1]["type"], "image_url");
assert_eq!(
content[1]["image_url"]["url"],
"data:image/jpeg;base64,base64data"
);
}
#[test]
fn build_request_text_only_still_uses_string_content() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hello")],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
assert!(messages[0]["content"].is_string());
assert_eq!(messages[0]["content"], "hello");
}
#[test]
fn build_request_user_with_audio_uses_input_audio() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message {
role: Role::User,
content: vec![
ContentBlock::Text {
text: "What does this say?".into(),
},
ContentBlock::Audio {
format: "ogg".into(),
data: "base64audio".into(),
},
],
}],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
assert_eq!(messages.len(), 1);
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 2);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "What does this say?");
assert_eq!(content[1]["type"], "input_audio");
assert_eq!(content[1]["input_audio"]["data"], "base64audio");
assert_eq!(content[1]["input_audio"]["format"], "ogg");
}
#[test]
fn build_request_audio_only_no_text() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message {
role: Role::User,
content: vec![ContentBlock::Audio {
format: "mp3".into(),
data: "audiodata".into(),
}],
}],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "input_audio");
}
#[test]
fn build_request_image_only_no_text() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message {
role: Role::User,
content: vec![ContentBlock::Image {
media_type: "image/png".into(),
data: "abc123".into(),
}],
}],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
let messages = body["messages"].as_array().unwrap();
let content = messages[0]["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "image_url");
}
#[test]
fn build_request_no_tool_choice_omits_field() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hi")],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
assert!(body.get("tool_choice").is_none());
}
#[test]
fn build_request_tool_choice_auto() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hi")],
tools: vec![],
max_tokens: 1024,
tool_choice: Some(ToolChoice::Auto),
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
assert_eq!(body["tool_choice"], "auto");
}
#[test]
fn build_request_tool_choice_any() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hi")],
tools: vec![],
max_tokens: 1024,
tool_choice: Some(ToolChoice::Any),
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
assert_eq!(body["tool_choice"], "required");
}
#[test]
fn build_request_tool_choice_specific_tool() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hi")],
tools: vec![],
max_tokens: 1024,
tool_choice: Some(ToolChoice::Tool {
name: "search".into(),
}),
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
assert_eq!(body["tool_choice"]["type"], "function");
assert_eq!(body["tool_choice"]["function"]["name"], "search");
}
#[test]
fn build_request_reasoning_effort_included() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hi")],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: Some(ReasoningEffort::Medium),
};
let body = build_openai_request("model", &request).unwrap();
assert_eq!(body["reasoning"]["effort"], "medium");
}
#[test]
fn build_request_reasoning_effort_high() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hi")],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: Some(ReasoningEffort::High),
};
let body = build_openai_request("model", &request).unwrap();
assert_eq!(body["reasoning"]["effort"], "high");
}
#[test]
fn build_request_no_reasoning_effort_omits_field() {
let request = CompletionRequest {
system: String::new(),
messages: vec![Message::user("hi")],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body = build_openai_request("model", &request).unwrap();
assert!(body.get("reasoning").is_none());
}
#[test]
fn parse_response_reasoning_tokens() {
let api = OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: Some("Hello!".into()),
tool_calls: None,
},
finish_reason: Some("stop".into()),
}],
usage: Some(OpenAiUsage {
prompt_tokens: 50,
completion_tokens: 10,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
reasoning_tokens: 25,
}),
};
let response = into_completion_response(api).unwrap();
assert_eq!(response.usage.reasoning_tokens, 25);
}
#[test]
fn full_conversation_roundtrip() {
use crate::llm::types::ToolResult;
let request1 = CompletionRequest {
system: "You are helpful.".into(),
messages: vec![Message::user("search for rust")],
tools: vec![ToolDefinition {
name: "search".into(),
description: "Search".into(),
input_schema: json!({"type": "object"}),
}],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body1 = build_openai_request("model", &request1).unwrap();
assert!(body1["messages"].as_array().unwrap().len() == 2);
let response1 = into_completion_response(OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: Some("Searching...".into()),
tool_calls: Some(vec![OpenAiToolCall {
id: "call_1".into(),
function: OpenAiFunction {
name: "search".into(),
arguments: r#"{"q":"rust"}"#.into(),
},
}]),
},
finish_reason: Some("tool_calls".into()),
}],
usage: None,
})
.unwrap();
let request2 = CompletionRequest {
system: "You are helpful.".into(),
messages: vec![
Message::user("search for rust"),
Message {
role: Role::Assistant,
content: response1.content,
},
Message::tool_results(vec![ToolResult::success("call_1", "Rust is great")]),
],
tools: vec![],
max_tokens: 1024,
tool_choice: None,
reasoning_effort: None,
};
let body2 = build_openai_request("model", &request2).unwrap();
let msgs = body2["messages"].as_array().unwrap();
assert_eq!(msgs.len(), 4);
assert_eq!(msgs[0]["role"], "system");
assert_eq!(msgs[1]["role"], "user");
assert_eq!(msgs[2]["role"], "assistant");
assert_eq!(msgs[3]["role"], "tool");
}
fn make_sse_data(chunks: &[&str]) -> String {
chunks
.iter()
.map(|c| format!("data: {c}\n\n"))
.collect::<Vec<_>>()
.join("")
+ "data: [DONE]\n\n"
}
#[tokio::test]
async fn stream_text_response() {
let sse = make_sse_data(&[
r#"{"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{"content":" world"},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}"#,
]);
let stream = futures::stream::iter(vec![Ok(Bytes::from(sse))]);
let received = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let r = received.clone();
let on_text: &crate::llm::OnText = &move |t: &str| {
r.lock().expect("lock").push(t.to_string());
};
let response = parse_openai_stream(stream, on_text).await.unwrap();
assert_eq!(response.text(), "Hello world");
assert_eq!(response.stop_reason, StopReason::EndTurn);
assert_eq!(response.usage.input_tokens, 10);
assert_eq!(response.usage.output_tokens, 5);
let texts = received.lock().expect("lock");
assert_eq!(*texts, vec!["Hello", " world"]);
}
#[tokio::test]
async fn stream_tool_call_response() {
let sse = make_sse_data(&[
r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"search","arguments":""}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"q\":"}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"rust\"}"}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":20,"completion_tokens":10}}"#,
]);
let stream = futures::stream::iter(vec![Ok(Bytes::from(sse))]);
let on_text: &crate::llm::OnText = &|_| {};
let response = parse_openai_stream(stream, on_text).await.unwrap();
assert_eq!(response.stop_reason, StopReason::ToolUse);
let calls = response.tool_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].name, "search");
assert_eq!(calls[0].input["q"], "rust");
}
#[tokio::test]
async fn stream_parallel_tool_calls() {
let sse = make_sse_data(&[
r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"search","arguments":""}},{"index":1,"id":"c2","function":{"name":"read","arguments":""}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}"}},{"index":1,"function":{"arguments":"{}"}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#,
]);
let stream = futures::stream::iter(vec![Ok(Bytes::from(sse))]);
let on_text: &crate::llm::OnText = &|_| {};
let response = parse_openai_stream(stream, on_text).await.unwrap();
let calls = response.tool_calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "search");
assert_eq!(calls[1].name, "read");
}
#[tokio::test]
async fn stream_text_with_tool_calls() {
let sse = make_sse_data(&[
r#"{"choices":[{"delta":{"content":"Let me search."},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"search","arguments":"{}"}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#,
]);
let stream = futures::stream::iter(vec![Ok(Bytes::from(sse))]);
let on_text: &crate::llm::OnText = &|_| {};
let response = parse_openai_stream(stream, on_text).await.unwrap();
assert_eq!(response.text(), "Let me search.");
assert_eq!(response.tool_calls().len(), 1);
}
#[tokio::test]
async fn stream_max_tokens() {
let sse = make_sse_data(&[
r#"{"choices":[{"delta":{"content":"trunc"},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"length"}]}"#,
]);
let stream = futures::stream::iter(vec![Ok(Bytes::from(sse))]);
let on_text: &crate::llm::OnText = &|_| {};
let response = parse_openai_stream(stream, on_text).await.unwrap();
assert_eq!(response.stop_reason, StopReason::MaxTokens);
}
#[tokio::test]
async fn stream_stop_with_tool_calls_normalizes() {
let sse = make_sse_data(&[
r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"c1","function":{"name":"search","arguments":"{}"}}]},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#,
]);
let stream = futures::stream::iter(vec![Ok(Bytes::from(sse))]);
let on_text: &crate::llm::OnText = &|_| {};
let response = parse_openai_stream(stream, on_text).await.unwrap();
assert_eq!(response.stop_reason, StopReason::ToolUse); assert_eq!(response.tool_calls().len(), 1);
}
#[test]
fn parse_response_with_cache_tokens() {
let api = OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: Some("Hello!".into()),
tool_calls: None,
},
finish_reason: Some("stop".into()),
}],
usage: Some(OpenAiUsage {
prompt_tokens: 100,
completion_tokens: 20,
cache_creation_input_tokens: 80,
cache_read_input_tokens: 60,
reasoning_tokens: 0,
}),
};
let response = into_completion_response(api).unwrap();
assert_eq!(response.usage.input_tokens, 100);
assert_eq!(response.usage.output_tokens, 20);
assert_eq!(response.usage.cache_creation_input_tokens, 80);
assert_eq!(response.usage.cache_read_input_tokens, 60);
}
#[test]
fn parse_response_cache_tokens_default_when_missing() {
let api = OpenAiResponse {
choices: vec![OpenAiChoice {
message: OpenAiMessage {
content: Some("Hello!".into()),
tool_calls: None,
},
finish_reason: Some("stop".into()),
}],
usage: Some(OpenAiUsage {
prompt_tokens: 50,
completion_tokens: 10,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
reasoning_tokens: 0,
}),
};
let response = into_completion_response(api).unwrap();
assert_eq!(response.usage.cache_creation_input_tokens, 0);
assert_eq!(response.usage.cache_read_input_tokens, 0);
}
#[tokio::test]
async fn stream_cache_tokens_passthrough() {
let sse = make_sse_data(&[
r#"{"choices":[{"delta":{"content":"hi"},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":10,"cache_creation_input_tokens":80,"cache_read_input_tokens":60}}"#,
]);
let stream = futures::stream::iter(vec![Ok(Bytes::from(sse))]);
let on_text: &crate::llm::OnText = &|_| {};
let response = parse_openai_stream(stream, on_text).await.unwrap();
assert_eq!(response.usage.cache_creation_input_tokens, 80);
assert_eq!(response.usage.cache_read_input_tokens, 60);
}
#[tokio::test]
async fn stream_chunked_delivery() {
let sse = make_sse_data(&[
r#"{"choices":[{"delta":{"content":"he"},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{"content":"llo"},"finish_reason":null}]}"#,
r#"{"choices":[{"delta":{},"finish_reason":"stop"}]}"#,
]);
let mid = sse.len() / 2;
let chunk1 = Bytes::from(sse[..mid].to_string());
let chunk2 = Bytes::from(sse[mid..].to_string());
let stream = futures::stream::iter(vec![Ok(chunk1), Ok(chunk2)]);
let on_text: &crate::llm::OnText = &|_| {};
let response = parse_openai_stream(stream, on_text).await.unwrap();
assert_eq!(response.text(), "hello");
}
#[tokio::test]
async fn stream_empty_choices_returns_retryable_error() {
let sse = make_sse_data(&[
r#"{"choices":[]}"#,
r#"{"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":0}}"#,
]);
let stream = futures::stream::iter(vec![Ok(Bytes::from(sse))]);
let on_text: &crate::llm::OnText = &|_| {};
let err = parse_openai_stream(stream, on_text).await.unwrap_err();
assert!(
err.to_string().contains("empty choices"),
"expected empty choices error, got: {err}"
);
match &err {
Error::Api { status, .. } => assert_eq!(*status, 502),
other => panic!("expected Error::Api, got: {other:?}"),
}
}
#[test]
fn model_name_returns_configured_model() {
let provider = OpenRouterProvider::new("key", "anthropic/claude-3-opus");
assert_eq!(provider.model_name(), Some("anthropic/claude-3-opus"));
}
}