use reqwest::{Url, blocking::multipart};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::client_routes::{dynamic_route_methods, static_route_methods};
use crate::error::{parse_api_error, reqwest_error_message};
use crate::observability::RequestTrace;
use crate::routes::{HttpMethod, MultipartFile, RawJsonRequest, RawMultipartRequest};
use crate::streaming::BlockingSseStream;
use crate::transport::{
QueryParams, endpoint_url_from_base, normalize_base_url, normalize_unchecked_base_url,
path_segment, with_query,
};
use crate::types::*;
use crate::{OpenRouterError, RequestOptions};
pub struct BlockingOpenRouterClient {
http: reqwest::blocking::Client,
base_url: Url,
}
impl BlockingOpenRouterClient {
pub fn new(http: reqwest::blocking::Client, base_url: impl Into<String>) -> Self {
Self::try_new(http, base_url).expect("invalid OpenRouter base URL")
}
pub fn try_new(
http: reqwest::blocking::Client,
base_url: impl Into<String>,
) -> Result<Self, OpenRouterError> {
Self::from_normalized_base_url(http, normalize_base_url(base_url.into()))
}
pub fn try_new_unchecked_base_url(
http: reqwest::blocking::Client,
base_url: impl Into<String>,
) -> Result<Self, OpenRouterError> {
Self::from_normalized_base_url(http, normalize_unchecked_base_url(base_url.into()))
}
fn from_normalized_base_url(
http: reqwest::blocking::Client,
base_url: Result<Url, String>,
) -> Result<Self, OpenRouterError> {
Ok(Self {
http,
base_url: base_url.map_err(OpenRouterError::InvalidBaseUrl)?,
})
}
pub fn http(&self) -> &reqwest::blocking::Client {
&self.http
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub fn raw_json(
&self,
api_key: Option<&str>,
request: RawJsonRequest,
) -> Result<Value, OpenRouterError> {
self.request_json_value(
request.method,
&request.path,
api_key,
&request.query,
request.body.as_ref(),
&request.options,
)
}
pub fn raw_binary(
&self,
api_key: Option<&str>,
request: RawJsonRequest,
) -> Result<BinaryResponse, OpenRouterError> {
self.request_binary(
request.method,
&request.path,
api_key,
&request.query,
request.body.as_ref(),
&request.options,
)
}
pub fn raw_multipart(
&self,
api_key: Option<&str>,
request: RawMultipartRequest,
) -> Result<Value, OpenRouterError> {
self.request_multipart_value(
request.method,
&request.path,
api_key,
&request.query,
request.files,
request.fields,
&request.options,
)
}
fn request_builder(
&self,
method: HttpMethod,
path: &str,
api_key: Option<&str>,
query: &[(String, String)],
options: &RequestOptions,
) -> Result<reqwest::blocking::RequestBuilder, OpenRouterError> {
let url = with_query(endpoint_url_from_base(&self.base_url, path)?, query);
let mut builder = self.http.request(method.into(), url);
if let Some(api_key) = api_key {
builder = builder.bearer_auth(api_key);
}
options.apply_blocking(builder)
}
fn request_json_no_body<T: DeserializeOwned>(
&self,
method: HttpMethod,
path: &str,
api_key: Option<&str>,
query: &[(String, String)],
options: &RequestOptions,
) -> Result<T, OpenRouterError> {
let builder = self.request_builder(method, path, api_key, query, options)?;
let trace = RequestTrace::start(method, path, query, api_key.is_some());
let resp = match builder.send() {
Ok(resp) => resp,
Err(e) => {
trace.transport_error(&e);
return Err(OpenRouterError::Transport(reqwest_error_message(&e)));
}
};
trace.response(resp.status(), resp.headers());
parse_json_response(resp)
}
fn request_json_body<B: Serialize + ?Sized, T: DeserializeOwned>(
&self,
method: HttpMethod,
path: &str,
api_key: Option<&str>,
query: &[(String, String)],
body: &B,
options: &RequestOptions,
) -> Result<T, OpenRouterError> {
let builder = self
.request_builder(method, path, api_key, query, options)?
.json(body);
let trace = RequestTrace::start(method, path, query, api_key.is_some());
let resp = match builder.send() {
Ok(resp) => resp,
Err(e) => {
trace.transport_error(&e);
return Err(OpenRouterError::Transport(reqwest_error_message(&e)));
}
};
trace.response(resp.status(), resp.headers());
parse_json_response(resp)
}
fn request_json_value(
&self,
method: HttpMethod,
path: &str,
api_key: Option<&str>,
query: &[(String, String)],
body: Option<&Value>,
options: &RequestOptions,
) -> Result<Value, OpenRouterError> {
match body {
Some(body) => self.request_json_body(method, path, api_key, query, body, options),
None => self.request_json_no_body(method, path, api_key, query, options),
}
}
fn request_binary(
&self,
method: HttpMethod,
path: &str,
api_key: Option<&str>,
query: &[(String, String)],
body: Option<&Value>,
options: &RequestOptions,
) -> Result<BinaryResponse, OpenRouterError> {
let mut builder = self.request_builder(method, path, api_key, query, options)?;
if let Some(body) = body {
builder = builder.json(body);
}
let trace = RequestTrace::start(method, path, query, api_key.is_some());
let resp = match builder.send() {
Ok(resp) => resp,
Err(e) => {
trace.transport_error(&e);
return Err(OpenRouterError::Transport(reqwest_error_message(&e)));
}
};
trace.response(resp.status(), resp.headers());
parse_binary_response(resp)
}
#[expect(
clippy::too_many_arguments,
reason = "transport helpers keep the HTTP request pieces explicit"
)]
fn request_multipart_value(
&self,
method: HttpMethod,
path: &str,
api_key: Option<&str>,
query: &[(String, String)],
files: Vec<MultipartFile>,
fields: Vec<(String, String)>,
options: &RequestOptions,
) -> Result<Value, OpenRouterError> {
let form = multipart_form(files, fields)?;
let builder = self
.request_builder(method, path, api_key, query, options)?
.multipart(form);
let trace = RequestTrace::start(method, path, query, api_key.is_some());
let resp = match builder.send() {
Ok(resp) => resp,
Err(e) => {
trace.transport_error(&e);
return Err(OpenRouterError::Transport(reqwest_error_message(&e)));
}
};
trace.response(resp.status(), resp.headers());
parse_json_response(resp)
}
fn stream_json_body<B: Serialize + ?Sized, T: DeserializeOwned>(
&self,
path: &str,
api_key: &str,
body: &B,
options: &RequestOptions,
) -> Result<BlockingSseStream<T>, OpenRouterError> {
let builder = self
.request_builder(HttpMethod::Post, path, Some(api_key), &[], options)?
.json(body);
let trace = RequestTrace::start(HttpMethod::Post, path, &[], true);
let resp = match builder.send() {
Ok(resp) => resp,
Err(e) => {
trace.transport_error(&e);
return Err(OpenRouterError::Transport(reqwest_error_message(&e)));
}
};
trace.response(resp.status(), resp.headers());
let status = resp.status();
if !status.is_success() {
let headers = resp.headers().clone();
let body = resp.text().unwrap_or_default();
return Err(parse_api_error(status, &headers, body));
}
Ok(BlockingSseStream::new(resp))
}
pub fn create_chat_completion(
&self,
api_key: &str,
request: ChatCompletionRequest,
) -> Result<ChatCompletionResponse, OpenRouterError> {
self.create_chat_completion_with_options(api_key, request, RequestOptions::default())
}
pub fn create_chat_completion_with_options(
&self,
api_key: &str,
request: ChatCompletionRequest,
options: RequestOptions,
) -> Result<ChatCompletionResponse, OpenRouterError> {
self.request_json_body(
HttpMethod::Post,
"chat/completions",
Some(api_key),
&[],
&request,
&options,
)
}
pub fn stream_chat_completion(
&self,
api_key: &str,
mut request: ChatCompletionRequest,
) -> Result<BlockingSseStream<ChatStreamChunk>, OpenRouterError> {
request.stream = Some(true);
self.stream_chat_completion_with_options(api_key, request, RequestOptions::default())
}
pub fn stream_chat_completion_with_options(
&self,
api_key: &str,
mut request: ChatCompletionRequest,
options: RequestOptions,
) -> Result<BlockingSseStream<ChatStreamChunk>, OpenRouterError> {
request.stream = Some(true);
self.stream_json_body("chat/completions", api_key, &request, &options)
}
pub fn create_response(
&self,
api_key: &str,
request: ResponsesRequest,
) -> Result<ResponsesResponse, OpenRouterError> {
self.create_response_with_options(api_key, request, RequestOptions::default())
}
pub fn create_response_with_options(
&self,
api_key: &str,
request: ResponsesRequest,
options: RequestOptions,
) -> Result<ResponsesResponse, OpenRouterError> {
self.request_json_body(
HttpMethod::Post,
"responses",
Some(api_key),
&[],
&request,
&options,
)
}
pub fn stream_response(
&self,
api_key: &str,
mut request: ResponsesRequest,
) -> Result<BlockingSseStream<StreamedResponsesEvent>, OpenRouterError> {
request.stream = Some(true);
self.stream_response_with_options(api_key, request, RequestOptions::default())
}
pub fn stream_response_with_options(
&self,
api_key: &str,
mut request: ResponsesRequest,
options: RequestOptions,
) -> Result<BlockingSseStream<StreamedResponsesEvent>, OpenRouterError> {
request.stream = Some(true);
self.stream_json_body("responses", api_key, &request, &options)
}
pub fn create_message(
&self,
api_key: &str,
request: MessagesRequest,
) -> Result<MessagesResponse, OpenRouterError> {
self.create_message_with_options(api_key, request, RequestOptions::default())
}
pub fn create_message_with_options(
&self,
api_key: &str,
request: MessagesRequest,
options: RequestOptions,
) -> Result<MessagesResponse, OpenRouterError> {
self.request_json_body(
HttpMethod::Post,
"messages",
Some(api_key),
&[],
&request,
&options,
)
}
pub fn stream_message(
&self,
api_key: &str,
mut request: MessagesRequest,
) -> Result<BlockingSseStream<MessagesStreamEvent>, OpenRouterError> {
request.stream = Some(true);
self.stream_message_with_options(api_key, request, RequestOptions::default())
}
pub fn stream_message_with_options(
&self,
api_key: &str,
mut request: MessagesRequest,
options: RequestOptions,
) -> Result<BlockingSseStream<MessagesStreamEvent>, OpenRouterError> {
request.stream = Some(true);
self.stream_json_body("messages", api_key, &request, &options)
}
pub fn generation_cost(
&self,
api_key: &str,
generation_id: &str,
) -> Result<Option<f64>, OpenRouterError> {
match self.get_generation(api_key, generation_id) {
Ok(generation) => Ok(generation.total_cost()),
Err(err) if is_not_found(&err) => Ok(None),
Err(err) => Err(err),
}
}
}
macro_rules! blocking_get_public {
($name:ident, $with:ident, $path:literal, $resp:ty) => {
pub fn $name(&self, query: QueryParams) -> Result<$resp, OpenRouterError> {
self.$with(query, RequestOptions::default())
}
pub fn $with(
&self,
query: QueryParams,
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
self.request_json_no_body(HttpMethod::Get, $path, None, &query, &options)
}
};
}
macro_rules! blocking_get_auth {
($name:ident, $with:ident, $path:literal, $resp:ty) => {
pub fn $name(&self, api_key: &str, query: QueryParams) -> Result<$resp, OpenRouterError> {
self.$with(api_key, query, RequestOptions::default())
}
pub fn $with(
&self,
api_key: &str,
query: QueryParams,
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
self.request_json_no_body(HttpMethod::Get, $path, Some(api_key), &query, &options)
}
};
}
macro_rules! blocking_post_auth {
($name:ident, $with:ident, $path:literal, $req:ty, $resp:ty) => {
pub fn $name(&self, api_key: &str, request: $req) -> Result<$resp, OpenRouterError> {
self.$with(api_key, request, RequestOptions::default())
}
pub fn $with(
&self,
api_key: &str,
request: $req,
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
self.request_json_body(
HttpMethod::Post,
$path,
Some(api_key),
&[],
&request,
&options,
)
}
};
}
impl BlockingOpenRouterClient {
static_route_methods!(blocking_get_auth, blocking_get_public, blocking_post_auth);
pub fn exchange_auth_code_for_api_key(
&self,
request: AuthKeyExchangeRequest,
) -> Result<AuthKeyExchangeResponse, OpenRouterError> {
self.exchange_auth_code_for_api_key_with_options(request, RequestOptions::default())
}
pub fn exchange_auth_code_for_api_key_with_options(
&self,
request: AuthKeyExchangeRequest,
options: RequestOptions,
) -> Result<AuthKeyExchangeResponse, OpenRouterError> {
self.request_json_body(HttpMethod::Post, "auth/keys", None, &[], &request, &options)
}
}
impl BlockingOpenRouterClient {
pub fn create_audio_speech(
&self,
api_key: &str,
request: SpeechRequest,
) -> Result<BinaryResponse, OpenRouterError> {
self.create_audio_speech_with_options(api_key, request, RequestOptions::default())
}
pub fn create_audio_speech_with_options(
&self,
api_key: &str,
request: SpeechRequest,
options: RequestOptions,
) -> Result<BinaryResponse, OpenRouterError> {
let body =
serde_json::to_value(request).map_err(|e| OpenRouterError::Decode(e.to_string()))?;
self.request_binary(
HttpMethod::Post,
"audio/speech",
Some(api_key),
&[],
Some(&body),
&options,
)
}
pub fn upload_file(
&self,
api_key: &str,
request: FileUploadRequest,
) -> Result<FileUploadResponse, OpenRouterError> {
self.upload_file_with_options(api_key, request, RequestOptions::default())
}
pub fn upload_file_with_options(
&self,
api_key: &str,
request: FileUploadRequest,
options: RequestOptions,
) -> Result<FileUploadResponse, OpenRouterError> {
let mut file = MultipartFile::new("file", request.bytes);
file.file_name = request.file_name;
file.content_type = request.content_type;
let value = self.request_multipart_value(
HttpMethod::Post,
"files",
Some(api_key),
&[],
vec![file],
Vec::new(),
&options,
)?;
serde_json::from_value(value).map_err(|e| OpenRouterError::Decode(e.to_string()))
}
pub fn get_generation(
&self,
api_key: &str,
generation_id: &str,
) -> Result<GenerationResponse, OpenRouterError> {
self.get_generation_with_options(api_key, generation_id, RequestOptions::default())
}
pub fn get_generation_with_options(
&self,
api_key: &str,
generation_id: &str,
options: RequestOptions,
) -> Result<GenerationResponse, OpenRouterError> {
self.request_json_no_body(
HttpMethod::Get,
"generation",
Some(api_key),
&[("id".to_owned(), generation_id.to_owned())],
&options,
)
}
pub fn get_generation_content(
&self,
api_key: &str,
generation_id: &str,
) -> Result<GenerationContentResponse, OpenRouterError> {
self.get_generation_content_with_options(api_key, generation_id, RequestOptions::default())
}
pub fn get_generation_content_with_options(
&self,
api_key: &str,
generation_id: &str,
options: RequestOptions,
) -> Result<GenerationContentResponse, OpenRouterError> {
self.request_json_no_body(
HttpMethod::Get,
"generation/content",
Some(api_key),
&[("id".to_owned(), generation_id.to_owned())],
&options,
)
}
}
macro_rules! dyn_get_auth {
($name:ident, $with:ident, $resp:ty, |$($arg:ident : $typ:ty),+| $path:expr) => {
pub fn $name(
&self,
api_key: &str,
$($arg: $typ,)+
query: QueryParams,
) -> Result<$resp, OpenRouterError> {
self.$with(api_key, $($arg,)+ query, RequestOptions::default())
}
pub fn $with(
&self,
api_key: &str,
$($arg: $typ,)+
query: QueryParams,
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
let path = $path;
self.request_json_no_body(HttpMethod::Get, &path, Some(api_key), &query, &options)
}
};
}
macro_rules! dyn_get_public {
($name:ident, $with:ident, $resp:ty, |$($arg:ident : $typ:ty),+| $path:expr) => {
pub fn $name(
&self,
$($arg: $typ,)+
query: QueryParams,
) -> Result<$resp, OpenRouterError> {
self.$with($($arg,)+ query, RequestOptions::default())
}
pub fn $with(
&self,
$($arg: $typ,)+
query: QueryParams,
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
let path = $path;
self.request_json_no_body(HttpMethod::Get, &path, None, &query, &options)
}
};
}
macro_rules! dyn_delete_auth {
($name:ident, $with:ident, $resp:ty, |$($arg:ident : $typ:ty),+| $path:expr) => {
pub fn $name(&self, api_key: &str, $($arg: $typ),+) -> Result<$resp, OpenRouterError> {
self.$with(api_key, $($arg,)+ RequestOptions::default())
}
pub fn $with(
&self,
api_key: &str,
$($arg: $typ,)+
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
let path = $path;
self.request_json_no_body(HttpMethod::Delete, &path, Some(api_key), &[], &options)
}
};
}
macro_rules! dyn_patch_auth {
($name:ident, $with:ident, $req:ty, $resp:ty, |$($arg:ident : $typ:ty),+| $path:expr) => {
pub fn $name(
&self,
api_key: &str,
$($arg: $typ,)+
request: $req,
) -> Result<$resp, OpenRouterError> {
self.$with(api_key, $($arg,)+ request, RequestOptions::default())
}
pub fn $with(
&self,
api_key: &str,
$($arg: $typ,)+
request: $req,
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
let path = $path;
self.request_json_body(HttpMethod::Patch, &path, Some(api_key), &[], &request, &options)
}
};
}
macro_rules! dyn_put_auth {
($name:ident, $with:ident, $req:ty, $resp:ty, |$($arg:ident : $typ:ty),+| $path:expr) => {
pub fn $name(
&self,
api_key: &str,
$($arg: $typ,)+
request: $req,
) -> Result<$resp, OpenRouterError> {
self.$with(api_key, $($arg,)+ request, RequestOptions::default())
}
pub fn $with(
&self,
api_key: &str,
$($arg: $typ,)+
request: $req,
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
let path = $path;
self.request_json_body(HttpMethod::Put, &path, Some(api_key), &[], &request, &options)
}
};
}
macro_rules! dyn_post_auth {
($name:ident, $with:ident, $req:ty, $resp:ty, |$($arg:ident : $typ:ty),+| $path:expr) => {
pub fn $name(
&self,
api_key: &str,
$($arg: $typ,)+
request: $req,
) -> Result<$resp, OpenRouterError> {
self.$with(api_key, $($arg,)+ request, RequestOptions::default())
}
pub fn $with(
&self,
api_key: &str,
$($arg: $typ,)+
request: $req,
options: RequestOptions,
) -> Result<$resp, OpenRouterError> {
let path = $path;
self.request_json_body(HttpMethod::Post, &path, Some(api_key), &[], &request, &options)
}
};
}
impl BlockingOpenRouterClient {
dynamic_route_methods!(
dyn_get_auth,
dyn_get_public,
dyn_delete_auth,
dyn_patch_auth,
dyn_put_auth,
dyn_post_auth
);
pub fn download_file_content(
&self,
api_key: &str,
file_id: &str,
) -> Result<BinaryResponse, OpenRouterError> {
self.download_file_content_with_options(api_key, file_id, RequestOptions::default())
}
pub fn download_file_content_with_options(
&self,
api_key: &str,
file_id: &str,
options: RequestOptions,
) -> Result<BinaryResponse, OpenRouterError> {
self.request_binary(
HttpMethod::Get,
&format!("files/{}/content", path_segment(file_id)),
Some(api_key),
&[],
None,
&options,
)
}
pub fn download_video_content(
&self,
api_key: &str,
job_id: &str,
) -> Result<BinaryResponse, OpenRouterError> {
self.download_video_content_with_options(api_key, job_id, RequestOptions::default())
}
pub fn download_video_content_with_options(
&self,
api_key: &str,
job_id: &str,
options: RequestOptions,
) -> Result<BinaryResponse, OpenRouterError> {
self.request_binary(
HttpMethod::Get,
&format!("videos/{}/content", path_segment(job_id)),
Some(api_key),
&[],
None,
&options,
)
}
}
fn parse_json_response<T: DeserializeOwned>(
resp: reqwest::blocking::Response,
) -> Result<T, OpenRouterError> {
let status = resp.status();
if !status.is_success() {
let headers = resp.headers().clone();
let body = resp.text().unwrap_or_default();
return Err(parse_api_error(status, &headers, body));
}
resp.json()
.map_err(|e| OpenRouterError::Decode(e.to_string()))
}
fn parse_binary_response(
resp: reqwest::blocking::Response,
) -> Result<BinaryResponse, OpenRouterError> {
let status = resp.status();
let headers = resp.headers().clone();
if !status.is_success() {
let body = resp.text().unwrap_or_default();
return Err(parse_api_error(status, &headers, body));
}
let content_type = headers
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let content_disposition = headers
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let bytes = resp
.bytes()
.map_err(|e| OpenRouterError::Transport(reqwest_error_message(&e)))?;
Ok(BinaryResponse {
bytes,
content_type,
content_disposition,
})
}
fn multipart_form(
files: Vec<MultipartFile>,
fields: Vec<(String, String)>,
) -> Result<multipart::Form, OpenRouterError> {
let mut form = multipart::Form::new();
for (key, value) in fields {
form = form.text(key, value);
}
for file in files {
let length = u64::try_from(file.bytes.len()).map_err(|_| {
OpenRouterError::InvalidHeader("multipart file is too large".to_owned())
})?;
let mut part =
multipart::Part::reader_with_length(std::io::Cursor::new(file.bytes), length);
if let Some(file_name) = file.file_name {
part = part.file_name(file_name);
}
if let Some(content_type) = file.content_type {
part = part
.mime_str(&content_type)
.map_err(|e| OpenRouterError::InvalidHeader(e.to_string()))?;
}
form = form.part(file.field_name, part);
}
Ok(form)
}
fn is_not_found(err: &OpenRouterError) -> bool {
matches!(err, OpenRouterError::Api(api) if api.status == 404)
}
#[cfg(test)]
mod tests {
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc;
use std::thread;
use crate::streaming::SseMessage;
use crate::{
AuthKeyExchangeRequest, BlockingOpenRouterClient, ChatCompletionRequest, ChatMessage,
HttpMethod, OpenRouterError, RawJsonRequest,
};
#[derive(Debug)]
struct RecordedRequest {
method: String,
path: String,
headers: Vec<(String, String)>,
body: String,
}
impl RecordedRequest {
fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
}
fn serve_once(
status: &'static str,
content_type: &'static str,
body: impl Into<String>,
) -> (String, mpsc::Receiver<RecordedRequest>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let body = body.into();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let request = read_request(&mut stream);
tx.send(request).unwrap();
let response = format!(
"HTTP/1.1 {status}\r\n\
content-type: {content_type}\r\n\
content-length: {}\r\n\
connection: close\r\n\
\r\n\
{body}",
body.len()
);
stream.write_all(response.as_bytes()).unwrap();
});
(format!("http://{addr}"), rx)
}
fn read_request(stream: &mut TcpStream) -> RecordedRequest {
let mut buf = Vec::new();
let mut header_end = None;
let mut content_length = 0;
loop {
let mut chunk = [0_u8; 1024];
let n = stream.read(&mut chunk).unwrap();
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if header_end.is_none() {
if let Some(end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
header_end = Some(end);
content_length = String::from_utf8_lossy(&buf[..end])
.lines()
.skip(1)
.find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
value.trim().parse().ok()
} else {
None
}
})
.unwrap_or(0);
}
}
if let Some(end) = header_end {
if buf.len() >= end + 4 + content_length {
break;
}
}
}
let header_end = header_end.unwrap_or(buf.len());
let header_text = String::from_utf8_lossy(&buf[..header_end]);
let mut lines = header_text.lines();
let request_line = lines.next().unwrap_or_default();
let mut request_parts = request_line.split_whitespace();
let method = request_parts.next().unwrap_or_default().to_owned();
let path = request_parts.next().unwrap_or_default().to_owned();
let headers = lines
.filter_map(|line| {
let (name, value) = line.split_once(':')?;
Some((name.trim().to_owned(), value.trim().to_owned()))
})
.collect::<Vec<_>>();
let body_start = header_end + 4;
let body_end = body_start + content_length;
let body = if body_end <= buf.len() {
String::from_utf8_lossy(&buf[body_start..body_end]).into_owned()
} else {
String::new()
};
RecordedRequest {
method,
path,
headers,
body,
}
}
#[test]
fn blocking_chat_completion_posts_expected_json() {
let (base_url, request) = serve_once(
"200 OK",
"application/json",
r#"{"id":"gen-123","choices":[{"message":{"role":"assistant","content":"ok"}}]}"#,
);
let client = BlockingOpenRouterClient::try_new_unchecked_base_url(
reqwest::blocking::Client::new(),
base_url,
)
.unwrap();
let response = client
.create_chat_completion(
"sk-test",
ChatCompletionRequest::new(
"openai/gpt-4o-mini",
vec![ChatMessage::user("Say hi.")],
),
)
.unwrap();
assert_eq!(response.id.as_deref(), Some("gen-123"));
let recorded = request.recv().unwrap();
assert_eq!(recorded.method, "POST");
assert_eq!(recorded.path, "/chat/completions");
assert!(recorded.body.contains("openai/gpt-4o-mini"));
}
#[test]
fn blocking_streaming_iterator_parses_events() {
let (base_url, _request) = serve_once(
"200 OK",
"text/event-stream",
"data: {\"id\":\"chunk-1\"}\n\ndata: [DONE]\n\n",
);
let client = BlockingOpenRouterClient::try_new_unchecked_base_url(
reqwest::blocking::Client::new(),
base_url,
)
.unwrap();
let mut stream = client
.stream_chat_completion(
"sk-test",
ChatCompletionRequest::new(
"openai/gpt-4o-mini",
vec![ChatMessage::user("Say hi.")],
),
)
.unwrap();
match stream.next().unwrap().unwrap() {
SseMessage::Data(chunk) => assert_eq!(chunk.id.as_deref(), Some("chunk-1")),
other => panic!("unexpected event: {other:?}"),
}
assert_eq!(stream.next().unwrap().unwrap(), SseMessage::Done);
}
#[test]
fn blocking_auth_code_exchange_does_not_send_authorization() {
let (base_url, request) = serve_once(
"200 OK",
"application/json",
r#"{"key":"sk-new","user_id":null}"#,
);
let client = BlockingOpenRouterClient::try_new_unchecked_base_url(
reqwest::blocking::Client::new(),
base_url,
)
.unwrap();
let response = client
.exchange_auth_code_for_api_key(AuthKeyExchangeRequest::new().with_field("code", "abc"))
.unwrap();
assert_eq!(response.extra["key"], "sk-new");
let recorded = request.recv().unwrap();
assert_eq!(recorded.method, "POST");
assert_eq!(recorded.path, "/auth/keys");
assert_eq!(recorded.header("authorization"), None);
}
#[test]
fn blocking_generation_cost_returns_none_for_not_yet_queryable_generation() {
let (base_url, request) = serve_once("404 Not Found", "application/json", "{}");
let client = BlockingOpenRouterClient::try_new_unchecked_base_url(
reqwest::blocking::Client::new(),
base_url,
)
.unwrap();
let cost = client.generation_cost("sk-cost", "gen-789").unwrap();
assert_eq!(cost, None);
let recorded = request.recv().unwrap();
assert_eq!(recorded.method, "GET");
assert_eq!(recorded.path, "/generation?id=gen-789");
}
#[test]
fn raw_absolute_paths_are_rejected_before_send() {
let client = BlockingOpenRouterClient::try_new_unchecked_base_url(
reqwest::blocking::Client::new(),
"http://127.0.0.1:9",
)
.unwrap();
let err = client
.raw_json(
Some("sk-test"),
RawJsonRequest::new(HttpMethod::Get, "https://user:pass@example.test/secret"),
)
.unwrap_err();
assert!(matches!(err, OpenRouterError::InvalidBaseUrl(_)));
}
#[test]
#[ignore = "requires OPENROUTER_API_KEY and live OpenRouter access"]
fn live_smoke_get_current_key() {
let api_key = std::env::var("OPENROUTER_API_KEY")
.expect("OPENROUTER_API_KEY must be set for live smoke tests");
let client = BlockingOpenRouterClient::try_new(
reqwest::blocking::Client::new(),
crate::DEFAULT_BASE_URL,
)
.unwrap();
let _ = client
.get_current_key(&api_key, Vec::new())
.expect("live get_current_key request should succeed");
}
}