use std::convert::Infallible;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::{
extract::{rejection::JsonRejection, State},
http::{header, Request, StatusCode},
middleware::{from_fn, Next},
response::{
sse::{Event, Sse},
IntoResponse, Json, Response,
},
routing::{get, post},
Router,
};
use futures::stream::{self, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tower_http::cors::{Any, CorsLayer};
mod anthropic_proxy;
use crate::types::{EmbeddingRequest, EmbeddingUsage, FinishReason};
use crate::{
BoxStream, ChatRequest, Content, FunctionDef, LlmError, LmrsClient, LogProbs, Message,
ResponseFormat, Role, StreamChunk, Tool, ToolCall, ToolChoice,
};
fn generate_id() -> String {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let rand = fastrand::u64(..);
format!("chatcmpl-{:016x}{:08x}", ts, rand)
}
fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ProxyChatRequest {
pub model: String,
pub messages: Vec<ProxyMessage>,
pub temperature: Option<f64>,
#[serde(alias = "max_completion_tokens")]
pub max_tokens: Option<u64>,
pub stream: bool,
pub top_p: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<Tool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_choice: Option<ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub functions: Option<Vec<FunctionDef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub function_call: Option<ProxyFunctionCallChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<ProxyStop>,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub presence_penalty: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frequency_penalty: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_logprobs: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_options: Option<ProxyStreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub service_tier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct ProxyStreamOptions {
pub include_usage: bool,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum ProxyFunctionCallChoice {
Mode(String),
Function { name: String },
}
impl ProxyFunctionCallChoice {
fn to_tool_choice(&self) -> ToolChoice {
match self {
ProxyFunctionCallChoice::Mode(mode) => ToolChoice::Mode(mode.clone()),
ProxyFunctionCallChoice::Function { name } => ToolChoice::function(name),
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum ProxyStop {
One(String),
Many(Vec<String>),
}
impl ProxyStop {
fn as_vec(&self) -> Vec<String> {
match self {
ProxyStop::One(stop) => vec![stop.clone()],
ProxyStop::Many(stop) => stop.clone(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct ProxyMessage {
pub role: String,
#[serde(default)]
pub content: Option<Content>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ProxyChatResponse {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<ProxyChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<ProxyUsage>,
}
#[derive(Debug, Serialize)]
pub struct ProxyChoice {
pub index: u32,
pub message: ProxyResponseMessage,
pub finish_reason: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub logprobs: Option<LogProbs>,
}
#[derive(Debug, Serialize)]
pub struct ProxyResponseMessage {
pub role: String,
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Serialize)]
pub struct ProxyUsage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
impl From<crate::types::Usage> for ProxyUsage {
fn from(u: crate::types::Usage) -> Self {
Self {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
}
}
}
#[derive(Debug, Serialize)]
pub struct ProxyStreamChunk {
pub id: String,
pub object: String,
pub created: u64,
pub model: String,
pub choices: Vec<ProxyStreamChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<ProxyUsage>,
}
#[derive(Debug, Serialize)]
pub struct ProxyStreamChoice {
pub index: u32,
pub delta: ProxyDelta,
pub finish_reason: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ProxyDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ProxyEmbeddingRequest {
pub model: String,
pub input: ProxyEmbeddingInput,
#[serde(skip_serializing_if = "Option::is_none")]
pub dimensions: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encoding_format: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum ProxyEmbeddingInput {
One(String),
Many(Vec<String>),
}
impl Default for ProxyEmbeddingInput {
fn default() -> Self {
ProxyEmbeddingInput::Many(Vec::new())
}
}
impl ProxyEmbeddingInput {
fn into_vec(self) -> Vec<String> {
match self {
ProxyEmbeddingInput::One(s) => vec![s],
ProxyEmbeddingInput::Many(v) => v,
}
}
}
#[derive(Debug, Serialize)]
pub struct ProxyEmbeddingResponse {
pub object: String,
pub data: Vec<ProxyEmbedding>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<ProxyEmbeddingUsage>,
}
#[derive(Debug, Serialize)]
pub struct ProxyEmbedding {
pub object: String,
pub index: usize,
pub embedding: Vec<f32>,
}
#[derive(Debug, Serialize)]
pub struct ProxyEmbeddingUsage {
pub prompt_tokens: u64,
pub total_tokens: u64,
}
impl From<EmbeddingUsage> for ProxyEmbeddingUsage {
fn from(u: EmbeddingUsage) -> Self {
Self {
prompt_tokens: u.prompt_tokens,
total_tokens: u.total_tokens,
}
}
}
#[derive(Debug, Serialize)]
pub struct ProxyError {
pub error: ProxyErrorDetail,
}
#[derive(Debug, Serialize)]
pub struct ProxyErrorDetail {
pub message: String,
#[serde(rename = "type")]
pub error_type: String,
pub param: Option<String>,
pub code: Option<String>,
}
#[derive(Clone)]
pub struct AppState {
pub llm: Arc<LmrsClient>,
}
fn default_cors() -> CorsLayer {
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
}
pub fn router(llm: Arc<LmrsClient>) -> Router {
let state = AppState { llm };
Router::new()
.route("/v1/chat/completions", post(handle_chat_completions))
.route("/v1/messages", post(anthropic_proxy::handle_messages))
.route("/v1/embeddings", post(handle_embeddings))
.route("/health", get(health_check))
.layer(default_cors())
.with_state(state)
}
pub fn router_with_auth(llm: Arc<LmrsClient>, expected_token: String) -> Router {
let state = AppState { llm };
let token = expected_token.clone();
Router::new()
.route("/v1/chat/completions", post(handle_chat_completions))
.route("/v1/messages", post(anthropic_proxy::handle_messages))
.route("/v1/embeddings", post(handle_embeddings))
.route("/health", get(health_check))
.with_state(state)
.layer(from_fn(move |req, next| {
let expected = token.clone();
check_bearer(expected, req, next)
}))
.layer(default_cors())
}
async fn check_bearer(expected: String, req: Request<axum::body::Body>, next: Next) -> Response {
match req
.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
{
Some(s) => match s.strip_prefix("Bearer ") {
Some(provided) if constant_time_eq(provided.trim().as_bytes(), expected.as_bytes()) => {
next.run(req).await
}
Some(_) => error_response(StatusCode::UNAUTHORIZED, "Invalid bearer token"),
None => error_response(
StatusCode::UNAUTHORIZED,
"Authorization header must use Bearer scheme",
),
},
None => {
let mut response =
error_response(StatusCode::UNAUTHORIZED, "Missing Authorization header");
response.headers_mut().insert(
header::WWW_AUTHENTICATE,
"Bearer".parse().expect("static header value parses"),
);
response
}
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
let diff = a.len() ^ b.len();
let mut acc = diff as u8;
for (x, y) in a.iter().zip(b.iter()) {
acc |= x ^ y;
}
acc == 0 } else {
let mut acc: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
acc |= x ^ y;
}
acc == 0
}
}
async fn health_check() -> Json<serde_json::Value> {
Json(serde_json::json!({"status": "ok"}))
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
pub async fn serve(llm: Arc<LmrsClient>, addr: &str) -> std::io::Result<()> {
let token = std::env::var("LLMRUST_PROXY_KEY")
.ok()
.filter(|s| !s.is_empty());
let app = match token {
Some(key) => {
eprintln!("[auth] enabled — bearer token required");
router_with_auth(llm, key)
}
None => {
eprintln!("[auth] DISABLED — set LLMRUST_PROXY_KEY=<secret> to require bearer auth");
router(llm)
}
};
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await
}
async fn handle_chat_completions(
State(state): State<AppState>,
payload: std::result::Result<Json<ProxyChatRequest>, JsonRejection>,
) -> Response {
let Json(req) = match payload {
Ok(req) => req,
Err(e) => {
tracing::error!("proxy: request JSON extraction failed");
return json_rejection_response(e);
}
};
tracing::info!(
model = &req.model,
stream = req.stream,
"proxy: chat completion request"
);
let chat_req = match convert_request(&req) {
Ok(r) => r,
Err(e) => {
tracing::error!(
model = &req.model,
error_kind = "request_conversion",
"proxy: request conversion failed"
);
return error_response(StatusCode::BAD_REQUEST, &e);
}
};
if req.stream {
let include_usage = req
.stream_options
.as_ref()
.is_some_and(|opts| opts.include_usage);
handle_stream(state, &req.model, chat_req, include_usage).await
} else {
handle_non_stream(state, &req.model, chat_req).await
}
}
async fn handle_non_stream(state: AppState, model: &str, req: ChatRequest) -> Response {
let (_, model_name) = match split_model(model) {
Ok(pair) => pair,
Err(e) => return error_response(StatusCode::BAD_REQUEST, e),
};
let model_name = model_name.to_string();
match state.llm.chat_with(model, req).await {
Ok(resp) => {
let has_tool_calls = resp.tool_calls.as_ref().is_some_and(|c| !c.is_empty());
let finish_reason = resp
.finish_reason
.unwrap_or(if has_tool_calls {
FinishReason::ToolCalls
} else {
FinishReason::Stop
})
.as_str()
.to_string();
let content = if has_tool_calls && resp.content.is_empty() {
None
} else {
Some(resp.content)
};
Json(ProxyChatResponse {
id: generate_id(),
object: "chat.completion".to_string(),
created: unix_timestamp(),
model: model_name.to_string(),
choices: vec![ProxyChoice {
index: 0,
message: ProxyResponseMessage {
role: "assistant".to_string(),
content,
tool_calls: resp.tool_calls,
},
finish_reason,
logprobs: resp.logprobs,
}],
usage: resp.usage.map(ProxyUsage::from),
})
.into_response()
}
Err(e) => proxy_error_from_llm_error(e),
}
}
async fn handle_embeddings(
State(state): State<AppState>,
payload: std::result::Result<Json<ProxyEmbeddingRequest>, JsonRejection>,
) -> Response {
let Json(req) = match payload {
Ok(req) => req,
Err(e) => {
tracing::error!("proxy: embeddings JSON extraction failed");
return json_rejection_response(e);
}
};
if let Some(ref fmt) = req.encoding_format {
if fmt != "float" {
tracing::error!(
model = %req.model,
encoding_format = %fmt,
"proxy: unsupported encoding_format"
);
return error_response_with_type(
StatusCode::BAD_REQUEST,
"unsupported encoding_format: only float is supported",
"invalid_request_error",
);
}
}
let inputs = req.input.into_vec();
if inputs.is_empty() {
return error_response_with_type(
StatusCode::BAD_REQUEST,
"input must not be empty",
"invalid_request_error",
);
}
tracing::info!(
model = %req.model,
input_count = inputs.len(),
"proxy: embedding request"
);
let mut embed_req = EmbeddingRequest::batch("", inputs);
if let Some(dim) = req.dimensions {
embed_req = embed_req.with_dimensions(dim);
}
if let Some(user) = req.user {
embed_req = embed_req.with_user(user);
}
match state.llm.embed_with(&req.model, embed_req).await {
Ok(resp) => {
let data: Vec<ProxyEmbedding> = resp
.data
.into_iter()
.map(|e| ProxyEmbedding {
object: "embedding".into(),
index: e.index,
embedding: e.embedding,
})
.collect();
let usage = resp.usage.map(ProxyEmbeddingUsage::from);
Json(ProxyEmbeddingResponse {
object: "list".into(),
data,
model: resp.model,
usage,
})
.into_response()
}
Err(e) => proxy_error_from_llm_error(e),
}
}
fn build_openai_sse_response(
inner_stream: BoxStream<'static, Result<StreamChunk, LlmError>>,
id: String,
created: u64,
model: String,
include_usage: bool,
) -> Response {
struct StreamState {
inner: BoxStream<'static, Result<StreamChunk, LlmError>>,
id: String,
created: u64,
model: String,
include_usage: bool,
role_sent: bool,
terminated: bool,
}
let state = StreamState {
inner: inner_stream,
id,
created,
model,
include_usage,
role_sent: false,
terminated: false,
};
let sse_stream = stream::unfold(state, |mut state| async move {
loop {
if state.terminated {
return None;
}
match state.inner.next().await {
Some(Ok(chunk)) => {
let has_tool_calls = chunk.tool_calls.as_ref().is_some_and(|c| !c.is_empty());
let mut finish_reason = chunk.finish_reason;
if finish_reason.is_none() && chunk.done {
finish_reason = Some(if has_tool_calls {
FinishReason::ToolCalls
} else {
FinishReason::Stop
});
}
let has_usage = chunk.usage.is_some();
let usage = if state.include_usage {
chunk.usage.map(ProxyUsage::from)
} else {
None
};
let content = if chunk.delta.is_empty() {
None
} else {
Some(chunk.delta)
};
let is_usage_only = has_usage
&& content.is_none()
&& finish_reason.is_none()
&& !has_tool_calls;
if is_usage_only && !state.include_usage {
continue;
}
let choices = if is_usage_only {
Vec::new()
} else {
let role = if state.role_sent {
None
} else {
state.role_sent = true;
Some("assistant".to_string())
};
vec![ProxyStreamChoice {
index: 0,
delta: ProxyDelta {
role,
content,
tool_calls: chunk.tool_calls,
},
finish_reason: finish_reason.map(|r| r.as_str().to_string()),
}]
};
let payload = ProxyStreamChunk {
id: state.id.clone(),
object: "chat.completion.chunk".to_string(),
created: state.created,
model: state.model.clone(),
choices,
usage,
};
let event = match serde_json::to_string(&payload) {
Ok(json) => {
std::result::Result::<_, Infallible>::Ok(Event::default().data(json))
}
Err(_) => {
tracing::error!(
model = state.model,
"failed to serialize SSE chunk, skipping event"
);
continue;
}
};
return Some((event, state));
}
Some(Err(e)) => {
state.terminated = true;
let error_payload = ProxyError {
error: ProxyErrorDetail {
message: e.to_string(),
error_type: "stream_error".to_string(),
param: None,
code: None,
},
};
let event = std::result::Result::<_, Infallible>::Ok(
Event::default()
.data(serde_json::to_string(&error_payload).unwrap_or_default()),
);
return Some((event, state));
}
None => return None,
}
}
});
let sse_stream: BoxStream<'static, std::result::Result<Event, Infallible>> =
Box::pin(sse_stream);
let done: BoxStream<'static, std::result::Result<Event, Infallible>> =
Box::pin(stream::once(async {
std::result::Result::<_, Infallible>::Ok(Event::default().data("[DONE]"))
}));
Sse::new(sse_stream.chain(done))
.keep_alive(
axum::response::sse::KeepAlive::new()
.interval(std::time::Duration::from_secs(15))
.text("keep-alive"),
)
.into_response()
}
async fn handle_stream(
state: AppState,
model: &str,
mut req: ChatRequest,
include_usage: bool,
) -> Response {
let (provider_name, model_name) = match split_model(model) {
Ok(pair) => pair,
Err(e) => return error_response(StatusCode::BAD_REQUEST, e),
};
req.model = model_name.to_string();
req.stream = true;
let provider = match state.llm.get_provider(provider_name).await {
Ok(p) => p,
Err(e) => return proxy_error_from_llm_error(e),
};
let stream_result = provider.stream(&req).await;
match stream_result {
Ok(inner_stream) => {
let id = generate_id();
let created = unix_timestamp();
build_openai_sse_response(
inner_stream,
id,
created,
model_name.to_string(),
include_usage,
)
}
Err(e) => proxy_error_from_llm_error(e),
}
}
fn convert_request(req: &ProxyChatRequest) -> Result<ChatRequest, String> {
if req.messages.is_empty() {
return Err("messages must contain at least one message".to_string());
}
if let Some(n) = req.n {
if n != 1 {
return Err(
"n must be 1 when using the llmrust proxy; multiple completions are not supported"
.to_string(),
);
}
}
let messages: Vec<Message> = req
.messages
.iter()
.map(|m| {
let role = match m.role.as_str() {
"system" => Role::System,
"user" => Role::User,
"assistant" => Role::Assistant,
"tool" => Role::Tool,
other => return Err(format!("Unknown role: {}", other)),
};
Ok(Message {
role,
content: m.content.clone().unwrap_or_default(),
tool_calls: m.tool_calls.clone(),
tool_call_id: m.tool_call_id.clone(),
name: m.name.clone(),
})
})
.collect::<Result<Vec<_>, _>>()?;
let tools = match &req.tools {
Some(tools) => Some(tools.clone()),
None => req.functions.as_ref().map(|functions| {
functions
.iter()
.cloned()
.map(|function| Tool {
tool_type: "function".to_string(),
function,
})
.collect()
}),
};
let tool_choice = req.tool_choice.clone().or_else(|| {
req.function_call
.as_ref()
.map(ProxyFunctionCallChoice::to_tool_choice)
});
Ok(ChatRequest {
model: String::new(),
messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
stream: req.stream,
top_p: req.top_p,
tools,
tool_choice,
response_format: req.response_format.clone(),
stop: req.stop.as_ref().map(ProxyStop::as_vec),
n: req.n,
seed: req.seed,
presence_penalty: req.presence_penalty,
frequency_penalty: req.frequency_penalty,
logprobs: req.logprobs,
top_logprobs: req.top_logprobs,
parallel_tool_calls: req.parallel_tool_calls,
service_tier: req.service_tier.clone(),
store: req.store,
metadata: req.metadata.clone(),
user: req.user.clone(),
extra: HashMap::new(),
})
}
pub(crate) fn split_model(model: &str) -> Result<(&str, &str), &'static str> {
let (provider, model) = model
.split_once('/')
.ok_or("model must be in 'provider/model' format")?;
if provider.is_empty() || model.is_empty() {
return Err("model must be in 'provider/model' format with non-empty provider and model");
}
Ok((provider, model))
}
fn proxy_error_from_llm_error(e: LlmError) -> Response {
let (status, message, error_type) = match &e {
LlmError::Api { status, message } => {
let code = StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY);
(code, message.clone(), api_error_type(code))
}
LlmError::UnknownProvider(_) => (
StatusCode::NOT_FOUND,
e.to_string(),
"invalid_request_error",
),
LlmError::Parse(_) => (
StatusCode::BAD_REQUEST,
e.to_string(),
"invalid_request_error",
),
LlmError::Unsupported { .. } => (
StatusCode::BAD_REQUEST,
e.to_string(),
"invalid_request_error",
),
_ => (StatusCode::BAD_GATEWAY, e.to_string(), "api_error"),
};
error_response_with_type(status, &message, error_type)
}
fn api_error_type(status: StatusCode) -> &'static str {
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => "authentication_error",
StatusCode::TOO_MANY_REQUESTS => "rate_limit_error",
StatusCode::BAD_REQUEST => "invalid_request_error",
_ => "api_error",
}
}
fn json_rejection_response(e: JsonRejection) -> Response {
let status = if e.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE {
StatusCode::UNSUPPORTED_MEDIA_TYPE
} else {
StatusCode::BAD_REQUEST
};
error_response_with_type(
status,
&format!("Invalid JSON request body: {e}"),
"invalid_request_error",
)
}
fn error_response(status: StatusCode, message: &str) -> Response {
error_response_with_type(status, message, "invalid_request_error")
}
fn error_response_with_type(status: StatusCode, message: &str, error_type: &str) -> Response {
let body = ProxyError {
error: ProxyErrorDetail {
message: message.to_string(),
error_type: error_type.to_string(),
param: None,
code: None,
},
};
(status, Json(body)).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
ChatResponse, Embedding, EmbeddingResponse, FinishReason, FunctionCall, ResponseFormat,
StreamChunk, Usage,
};
use crate::{BoxStream, Provider, Result};
use async_trait::async_trait;
use axum::body::{to_bytes, Body};
use axum::http::Request;
use std::sync::Mutex;
use tower::ServiceExt;
struct MockProvider;
#[async_trait::async_trait]
impl Provider for MockProvider {
async fn chat(&self, _req: &ChatRequest) -> Result<ChatResponse> {
Ok(ChatResponse {
content: "mocked reply".to_string(),
model: "mock-model".to_string(),
usage: Some(Usage {
prompt_tokens: 3,
completion_tokens: 5,
total_tokens: 8,
}),
..Default::default()
})
}
async fn stream(
&self,
_req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
let chunks: Vec<Result<StreamChunk>> = vec![
Ok(StreamChunk {
delta: "mock ".to_string(),
..Default::default()
}),
Ok(StreamChunk {
delta: "stream".to_string(),
..Default::default()
}),
Ok(StreamChunk {
done: true,
finish_reason: Some(FinishReason::Stop),
..Default::default()
}),
Ok(StreamChunk {
usage: Some(Usage {
prompt_tokens: 3,
completion_tokens: 5,
total_tokens: 8,
}),
..Default::default()
}),
];
Ok(Box::pin(futures::stream::iter(chunks)))
}
}
struct ToolCallProvider;
#[async_trait::async_trait]
impl Provider for ToolCallProvider {
async fn chat(&self, _req: &ChatRequest) -> Result<ChatResponse> {
Ok(ChatResponse {
content: String::new(),
model: "tool-model".to_string(),
tool_calls: Some(vec![ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "get_weather".to_string(),
arguments: "{\"city\":\"SF\"}".to_string(),
},
}]),
finish_reason: Some(FinishReason::ToolCalls),
..Default::default()
})
}
async fn stream(
&self,
_req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
let chunks: Vec<Result<StreamChunk>> = vec![Ok(StreamChunk {
done: true,
finish_reason: Some(FinishReason::ToolCalls),
tool_calls: Some(vec![ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "get_weather".to_string(),
arguments: "{\"city\":\"SF\"}".to_string(),
},
}]),
..Default::default()
})];
Ok(Box::pin(futures::stream::iter(chunks)))
}
}
struct ApiErrorProvider;
#[async_trait::async_trait]
impl Provider for ApiErrorProvider {
async fn chat(&self, _req: &ChatRequest) -> Result<ChatResponse> {
Err(LlmError::Api {
status: StatusCode::TOO_MANY_REQUESTS.as_u16(),
message: "rate limited".to_string(),
})
}
async fn stream(
&self,
_req: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
Err(LlmError::Api {
status: StatusCode::TOO_MANY_REQUESTS.as_u16(),
message: "rate limited".to_string(),
})
}
}
fn build_request(body: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("failed to build test request")
}
fn sse_json_events(text: &str) -> Vec<serde_json::Value> {
text.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim)
.filter(|data| !data.is_empty() && *data != "[DONE]")
.map(|data| serde_json::from_str(data).expect("SSE data is valid JSON"))
.collect()
}
#[tokio::test]
async fn router_can_be_built() {
let llm = Arc::new(LmrsClient::new());
let _router = router(llm);
}
#[tokio::test]
async fn non_stream_chat_with_mock_provider() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "mock/test-model",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value =
serde_json::from_slice(&bytes).expect("body is not valid JSON");
assert_eq!(json["model"], "test-model");
assert_eq!(json["object"], "chat.completion");
assert_eq!(json["choices"][0]["message"]["role"], "assistant");
assert_eq!(json["choices"][0]["message"]["content"], "mocked reply");
assert_eq!(json["choices"][0]["finish_reason"], "stop");
assert_eq!(json["usage"]["prompt_tokens"], 3);
assert_eq!(json["usage"]["completion_tokens"], 5);
assert_eq!(json["usage"]["total_tokens"], 8);
}
#[tokio::test]
async fn non_stream_forwards_tool_calls_and_finish_reason() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("tool", Arc::new(ToolCallProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "tool/test-model",
"messages": [{"role": "user", "content": "weather?"}],
"tools": [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}]
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["choices"][0]["finish_reason"], "tool_calls");
assert!(json["choices"][0]["message"]["content"].is_null());
assert_eq!(
json["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
"get_weather"
);
}
#[tokio::test]
async fn proxy_accepts_multimodal_content() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "mock/test-model",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "what is in this image?"},
{"type": "image_url", "image_url": {"url": "https://example.com/cat.png"}}
]
}],
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["choices"][0]["message"]["content"], "mocked reply");
}
#[tokio::test]
async fn proxy_accepts_string_stop_sequence() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "mock/test-model",
"messages": [{"role": "user", "content": "hi"}],
"stop": "END"
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn malformed_json_returns_proxy_error_body() {
let llm = Arc::new(LmrsClient::new());
let app = router(llm);
let response = app
.oneshot(build_request("{"))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"]["type"], "invalid_request_error");
assert!(json["error"]["message"]
.as_str()
.unwrap()
.contains("Invalid JSON request body"));
assert!(json["error"]["param"].is_null());
assert!(json["error"]["code"].is_null());
}
#[tokio::test]
async fn stream_forwards_finish_reason_and_usage() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "mock/test-model",
"messages": [{"role": "user", "content": "hi"}],
"stream": true,
"stream_options": {"include_usage": true},
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let text = String::from_utf8(bytes.to_vec()).expect("stream body is valid UTF-8");
assert!(
text.contains("mock "),
"expected streamed content delta, got: {text}"
);
assert!(
text.contains("\"finish_reason\":\"stop\""),
"expected finish_reason to be forwarded, got: {text}"
);
assert!(
text.contains("\"total_tokens\":8"),
"expected usage to be forwarded, got: {text}"
);
assert!(
text.contains("[DONE]"),
"expected terminal [DONE] marker, got: {text}"
);
let events = sse_json_events(&text);
assert_eq!(events[0]["choices"][0]["delta"]["role"], "assistant");
assert_eq!(events[0]["choices"][0]["delta"]["content"], "mock ");
assert!(
events[1]["choices"][0]["delta"].get("role").is_none(),
"role should only be emitted on the first delta: {text}"
);
assert_eq!(events[1]["choices"][0]["delta"]["content"], "stream");
assert_eq!(events[2]["choices"][0]["finish_reason"], "stop");
assert!(
events[3]["choices"].as_array().unwrap().is_empty(),
"usage-only chunk should have empty choices: {text}"
);
assert_eq!(events[3]["usage"]["total_tokens"], 8);
}
#[tokio::test]
async fn stream_omits_usage_without_stream_options() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "mock/test-model",
"messages": [{"role": "user", "content": "hi"}],
"stream": true,
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let text = String::from_utf8(bytes.to_vec()).expect("stream body is valid UTF-8");
let events = sse_json_events(&text);
assert_eq!(
events.len(),
3,
"usage-only event should be skipped: {text}"
);
assert!(
events.iter().all(|event| event.get("usage").is_none()),
"usage should be omitted unless requested: {text}"
);
assert_eq!(events[0]["choices"][0]["delta"]["role"], "assistant");
assert_eq!(events[2]["choices"][0]["finish_reason"], "stop");
assert!(
text.contains("[DONE]"),
"expected terminal [DONE] marker, got: {text}"
);
}
#[tokio::test]
async fn stream_forwards_tool_calls() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("tool", Arc::new(ToolCallProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "tool/test-model",
"messages": [{"role": "user", "content": "weather?"}],
"stream": true,
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let text = String::from_utf8(bytes.to_vec()).expect("stream body is valid UTF-8");
assert!(text.contains("\"finish_reason\":\"tool_calls\""));
assert!(text.contains("\"tool_calls\""));
assert!(text.contains("\"name\":\"get_weather\""));
}
#[tokio::test]
async fn unknown_provider_returns_404() {
let llm = Arc::new(LmrsClient::new());
let app = router(llm);
let body = serde_json::json!({
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let message = json["error"]["message"]
.as_str()
.expect("error.message is not a string");
assert!(
message.contains("openai"),
"expected error to mention provider name 'openai', got: {}",
message
);
}
#[tokio::test]
async fn upstream_rate_limit_uses_openai_error_type() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("api", Arc::new(ApiErrorProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "api/test-model",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"]["message"], "rate limited");
assert_eq!(json["error"]["type"], "rate_limit_error");
assert!(json["error"]["param"].is_null());
assert!(json["error"]["code"].is_null());
}
#[tokio::test]
async fn invalid_model_format_returns_400() {
let llm = Arc::new(LmrsClient::new());
let app = router(llm);
let body = serde_json::json!({
"model": "no-slash-here",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn empty_messages_returns_400() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "mock/test",
"messages": [],
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"]["type"], "invalid_request_error");
assert!(json["error"]["message"]
.as_str()
.unwrap()
.contains("messages"));
}
#[tokio::test]
async fn invalid_role_returns_400() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router(llm);
let body = serde_json::json!({
"model": "mock/test",
"messages": [{"role": "wizard", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request(&body))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let message = json["error"]["message"]
.as_str()
.expect("error.message is not a string");
assert!(
message.contains("wizard"),
"expected error to mention role 'wizard', got: {}",
message
);
}
#[tokio::test]
async fn split_model_helper_handles_valid_input() {
let (provider, model) = split_model("openai/gpt-4o").expect("parse should succeed");
assert_eq!(provider, "openai");
assert_eq!(model, "gpt-4o");
}
#[tokio::test]
async fn split_model_helper_rejects_invalid_input() {
assert!(split_model("nope").is_err());
assert!(split_model("/gpt-4o").is_err());
assert!(split_model("openai/").is_err());
}
const TEST_TOKEN: &str = "secret-token-123";
fn build_request_with_auth(body: &str, auth_header: Option<&str>) -> Request<Body> {
let mut builder = Request::builder()
.method("POST")
.uri("/v1/chat/completions")
.header("content-type", "application/json");
if let Some(auth) = auth_header {
builder = builder.header("authorization", auth);
}
builder
.body(Body::from(body.to_string()))
.expect("failed to build test request")
}
#[tokio::test]
async fn auth_missing_header_returns_401() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router_with_auth(llm, TEST_TOKEN.to_string());
let body = serde_json::json!({
"model": "mock/test",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request_with_auth(&body, None))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert!(response.headers().contains_key("www-authenticate"));
}
#[tokio::test]
async fn auth_wrong_token_returns_401() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router_with_auth(llm, TEST_TOKEN.to_string());
let body = serde_json::json!({
"model": "mock/test",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request_with_auth(&body, Some("Bearer wrong-token")))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn auth_malformed_header_returns_401() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router_with_auth(llm, TEST_TOKEN.to_string());
let body = serde_json::json!({
"model": "mock/test",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request_with_auth(&body, Some("Basic dXNlcjpwYXNz")))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn auth_valid_token_passes_through() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let app = router_with_auth(llm, TEST_TOKEN.to_string());
let body = serde_json::json!({
"model": "mock/test",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let response = app
.oneshot(build_request_with_auth(
&body,
Some("Bearer secret-token-123"),
))
.await
.expect("request failed");
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body read failed");
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["choices"][0]["message"]["content"], "mocked reply");
}
#[tokio::test]
async fn serve_starts_and_answers_health() {
let llm = Arc::new(LmrsClient::new());
let addr = "127.0.0.1:0";
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("bind failed");
let bound_addr = listener.local_addr().unwrap();
let app = router(llm);
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(std::future::pending())
.await
.ok();
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let resp = reqwest::Client::new()
.get(format!("http://{}/health", bound_addr))
.send()
.await
.expect("health request failed");
assert_eq!(resp.status(), StatusCode::OK);
let json: serde_json::Value = resp.json().await.unwrap();
assert_eq!(json["status"], "ok");
}
#[tokio::test]
async fn serve_with_bearer_requires_auth() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom("mock", Arc::new(MockProvider)).await;
let addr = "127.0.0.1:0";
let listener = tokio::net::TcpListener::bind(addr)
.await
.expect("bind failed");
let bound_addr = listener.local_addr().unwrap();
let app = router_with_auth(llm, "test-secret".to_string());
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(std::future::pending())
.await
.ok();
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let body = serde_json::json!({
"model": "mock/test",
"messages": [{"role": "user", "content": "hi"}],
})
.to_string();
let resp = reqwest::Client::new()
.post(format!("http://{}/v1/chat/completions", bound_addr))
.header("content-type", "application/json")
.body(body.clone())
.send()
.await
.expect("request failed");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let resp = reqwest::Client::new()
.post(format!("http://{}/v1/chat/completions", bound_addr))
.header("content-type", "application/json")
.header("authorization", "Bearer test-secret")
.body(body)
.send()
.await
.expect("request failed");
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn convert_request_handles_tool_role() {
let raw = serde_json::json!({
"model": "openai/gpt-4o",
"messages": [
{"role": "user", "content": "What's the weather?"},
{"role": "assistant", "content": null, "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_1", "name": "get_weather", "content": "sunny"}
]
})
.to_string();
let req: ProxyChatRequest = serde_json::from_str(&raw).unwrap();
let chat_req = convert_request(&req).expect("should not fail");
assert_eq!(chat_req.messages.len(), 3);
assert_eq!(chat_req.messages[2].role, Role::Tool);
assert_eq!(chat_req.messages[2].tool_call_id.as_deref(), Some("call_1"));
assert_eq!(chat_req.messages[2].name.as_deref(), Some("get_weather"));
assert!(chat_req.messages[1].tool_calls.is_some());
assert!(chat_req.messages[1].content.is_empty());
}
#[test]
fn convert_request_forwards_tools_and_tool_choice() {
let raw = serde_json::json!({
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "f", "parameters": {}}}],
"tool_choice": "auto"
})
.to_string();
let req: ProxyChatRequest = serde_json::from_str(&raw).unwrap();
let chat_req = convert_request(&req).expect("should not fail");
assert!(chat_req.tools.is_some());
assert!(chat_req.tool_choice.is_some());
}
#[test]
fn convert_request_accepts_legacy_functions_and_function_call() {
let raw = serde_json::json!({
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"functions": [{
"name": "get_weather",
"description": "Get the weather",
"parameters": {"type": "object"}
}],
"function_call": {"name": "get_weather"}
})
.to_string();
let req: ProxyChatRequest = serde_json::from_str(&raw).unwrap();
let chat_req = convert_request(&req).expect("should not fail");
let tools = chat_req.tools.expect("legacy functions become tools");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].tool_type, "function");
assert_eq!(tools[0].function.name, "get_weather");
assert_eq!(
tools[0].function.description.as_deref(),
Some("Get the weather")
);
match chat_req
.tool_choice
.expect("function_call becomes tool_choice")
{
ToolChoice::Function { function, .. } => {
assert_eq!(function.name, "get_weather");
}
other => panic!("expected forced function tool_choice, got {other:?}"),
}
}
#[test]
fn convert_request_prefers_modern_tools_over_legacy_functions() {
let raw = serde_json::json!({
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"tools": [{
"type": "function",
"function": {"name": "modern_tool", "parameters": {}}
}],
"tool_choice": "auto",
"functions": [{
"name": "legacy_function",
"parameters": {}
}],
"function_call": {"name": "legacy_function"}
})
.to_string();
let req: ProxyChatRequest = serde_json::from_str(&raw).unwrap();
let chat_req = convert_request(&req).expect("should not fail");
let tools = chat_req.tools.expect("modern tools present");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function.name, "modern_tool");
assert_eq!(chat_req.tool_choice, Some(ToolChoice::auto()));
}
#[test]
fn convert_request_forwards_advanced_openai_fields() {
let raw = serde_json::json!({
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"response_format": {"type": "json_object"},
"stop": ["END"],
"n": 1,
"seed": 42,
"presence_penalty": 0.5,
"frequency_penalty": -0.25,
"logprobs": true,
"top_logprobs": 3,
"max_completion_tokens": 64,
"parallel_tool_calls": false,
"service_tier": "flex",
"store": true,
"metadata": {"trace_id": "abc"},
"user": "user-123"
})
.to_string();
let req: ProxyChatRequest = serde_json::from_str(&raw).unwrap();
let chat_req = convert_request(&req).expect("should not fail");
assert_eq!(chat_req.response_format, Some(ResponseFormat::JsonObject));
assert_eq!(chat_req.stop, Some(vec!["END".to_string()]));
assert_eq!(chat_req.n, Some(1));
assert_eq!(chat_req.seed, Some(42));
assert_eq!(chat_req.presence_penalty, Some(0.5));
assert_eq!(chat_req.frequency_penalty, Some(-0.25));
assert_eq!(chat_req.logprobs, Some(true));
assert_eq!(chat_req.top_logprobs, Some(3));
assert_eq!(chat_req.max_tokens, Some(64));
assert_eq!(chat_req.parallel_tool_calls, Some(false));
assert_eq!(chat_req.service_tier.as_deref(), Some("flex"));
assert_eq!(chat_req.store, Some(true));
assert_eq!(
chat_req.metadata,
Some(serde_json::json!({"trace_id": "abc"}))
);
assert_eq!(chat_req.user.as_deref(), Some("user-123"));
}
#[test]
fn convert_request_accepts_string_stop_sequence() {
let raw = serde_json::json!({
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "hi"}],
"stop": "END"
})
.to_string();
let req: ProxyChatRequest = serde_json::from_str(&raw).unwrap();
let chat_req = convert_request(&req).expect("should not fail");
assert_eq!(chat_req.stop, Some(vec!["END".to_string()]));
}
#[test]
fn convert_request_rejects_empty_messages() {
let req = ProxyChatRequest {
model: "openai/gpt-4o".to_string(),
..Default::default()
};
let err = convert_request(&req).expect_err("empty messages should fail");
assert!(err.contains("messages"));
}
#[test]
fn convert_request_accepts_missing_n() {
let raw = r#"{"model":"openai/gpt-4o","messages":[{"role":"user","content":"hi"}]}"#;
let req: ProxyChatRequest = serde_json::from_str(raw).unwrap();
let chat_req = convert_request(&req).expect("missing n should be accepted");
assert_eq!(chat_req.n, None);
}
#[test]
fn convert_request_accepts_n_one() {
let raw = r#"{"model":"openai/gpt-4o","messages":[{"role":"user","content":"hi"}],"n":1}"#;
let req: ProxyChatRequest = serde_json::from_str(raw).unwrap();
let chat_req = convert_request(&req).expect("n=1 should be accepted");
assert_eq!(chat_req.n, Some(1));
}
#[test]
fn convert_request_rejects_n_greater_than_one() {
let raw = r#"{"model":"openai/gpt-4o","messages":[{"role":"user","content":"hi"}],"n":2}"#;
let req: ProxyChatRequest = serde_json::from_str(raw).unwrap();
let err = convert_request(&req).expect_err("n>1 should be rejected");
assert!(err.contains("n"), "error should mention n: {err}");
assert!(
err.contains("multiple") || err.contains("not supported"),
"error should explain limitation: {err}"
);
}
#[test]
fn convert_request_rejects_n_zero() {
let raw = r#"{"model":"openai/gpt-4o","messages":[{"role":"user","content":"hi"}],"n":0}"#;
let req: ProxyChatRequest = serde_json::from_str(raw).unwrap();
let err = convert_request(&req).expect_err("n=0 should be rejected");
assert!(err.contains("n"), "error should mention n: {err}");
}
async fn collect_sse(resp: Response) -> String {
let bytes = to_bytes(resp.into_body(), usize::MAX)
.await
.expect("body read");
String::from_utf8(bytes.to_vec()).expect("valid UTF-8")
}
#[tokio::test]
async fn openai_stream_error_event_is_emitted_before_done() {
let inner = Box::pin(futures::stream::iter([
Ok(StreamChunk {
delta: "hello".to_string(),
..Default::default()
}),
Err(LlmError::Stream("upstream disconnected".to_string())),
]));
let sse = build_openai_sse_response(inner, "id1".into(), 0, "m".into(), true);
let text = collect_sse(sse).await;
let err_pos = text
.find("stream_error")
.expect("stream_error must be emitted");
let done_pos = text.find("[DONE]").expect("DONE must be emitted");
assert!(err_pos < done_pos, "stream_error must appear before [DONE]");
}
#[tokio::test]
async fn openai_stream_error_stops_after_first_error() {
let inner = Box::pin(futures::stream::iter([
Ok(StreamChunk {
delta: "before".to_string(),
..Default::default()
}),
Err(LlmError::Stream("boom".to_string())),
Ok(StreamChunk {
delta: "after".to_string(),
..Default::default()
}),
]));
let sse = build_openai_sse_response(inner, "id2".into(), 0, "m".into(), true);
let text = collect_sse(sse).await;
assert!(text.contains("before"));
assert!(text.contains("stream_error"));
assert!(
!text.contains("after"),
"must not consume chunks after error"
);
}
#[tokio::test]
async fn openai_stream_normal_text_still_emits_role_and_done() {
let inner = Box::pin(futures::stream::iter([
Ok(StreamChunk {
delta: "hello".to_string(),
..Default::default()
}),
Ok(StreamChunk {
delta: " world".to_string(),
done: true,
finish_reason: Some(FinishReason::Stop),
..Default::default()
}),
]));
let sse = build_openai_sse_response(inner, "id3".into(), 0, "m".into(), false);
let text = collect_sse(sse).await;
assert!(text.contains(r#""role":"assistant""#));
assert_eq!(
text.matches(r#""role":"assistant""#).count(),
1,
"role must appear only once"
);
assert!(text.contains("hello"));
assert!(text.contains(" world"));
assert!(text.contains("[DONE]"));
}
#[tokio::test]
async fn openai_stream_usage_only_emits_when_requested() {
let inner = Box::pin(futures::stream::iter([
Ok(StreamChunk {
delta: "hi".to_string(),
done: true,
finish_reason: Some(FinishReason::Stop),
..Default::default()
}),
Ok(StreamChunk {
usage: Some(Usage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
}),
..Default::default()
}),
]));
let sse = build_openai_sse_response(inner, "id4".into(), 0, "m".into(), true);
let text = collect_sse(sse).await;
assert!(text.contains(r#""prompt_tokens":10"#));
}
#[tokio::test]
async fn openai_stream_usage_only_skipped_when_not_requested() {
let inner = Box::pin(futures::stream::iter([
Ok(StreamChunk {
usage: Some(Usage {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
}),
..Default::default()
}),
Ok(StreamChunk {
delta: "next".to_string(),
done: true,
finish_reason: Some(FinishReason::Stop),
..Default::default()
}),
]));
let sse = build_openai_sse_response(inner, "id5".into(), 0, "m".into(), false);
let text = collect_sse(sse).await;
assert!(!text.contains("\"usage\""));
assert!(text.contains("next"));
assert!(text.contains("[DONE]"));
}
#[tokio::test]
async fn openai_stream_tool_calls_still_serialize() {
let inner = Box::pin(futures::stream::iter([Ok(StreamChunk {
tool_calls: Some(vec![ToolCall {
id: "call_0".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "lookup".to_string(),
arguments: r#"{"q":"rust"}"#.to_string(),
},
}]),
finish_reason: Some(FinishReason::ToolCalls),
..Default::default()
})]));
let sse = build_openai_sse_response(inner, "id6".into(), 0, "m".into(), false);
let text = collect_sse(sse).await;
assert!(text.contains("tool_calls"));
assert!(text.contains("call_0"));
assert!(text.contains("lookup"));
}
struct EmbedMockProvider {
captured: Arc<Mutex<Option<EmbeddingRequest>>>,
}
#[async_trait]
impl Provider for EmbedMockProvider {
async fn chat(&self, _: &ChatRequest) -> Result<ChatResponse> {
unimplemented!()
}
async fn stream(&self, _: &ChatRequest) -> Result<BoxStream<'static, Result<StreamChunk>>> {
unimplemented!()
}
async fn embed(&self, req: &EmbeddingRequest) -> Result<EmbeddingResponse> {
*self.captured.lock().unwrap() = Some(req.clone());
Ok(EmbeddingResponse {
model: req.model.clone(),
data: req
.input
.iter()
.enumerate()
.map(|(i, _)| Embedding {
index: i,
embedding: vec![1.0_f32],
})
.collect(),
usage: Some(EmbeddingUsage {
prompt_tokens: req.input.len() as u64,
total_tokens: req.input.len() as u64,
}),
})
}
}
fn embed_body(model: &str, input: serde_json::Value) -> Request<Body> {
let body = serde_json::json!({
"model": model,
"input": input
});
Request::builder()
.method("POST")
.uri("/v1/embeddings")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.expect("build request")
}
#[tokio::test]
async fn proxy_embeddings_route_returns_openai_shape() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom(
"fake",
Arc::new(EmbedMockProvider {
captured: Arc::new(Mutex::new(None)),
}),
)
.await;
let app = router(llm);
let resp = app
.oneshot(embed_body("fake/text-emb", serde_json::json!("hello")))
.await
.expect("request failed");
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["object"], "list");
assert_eq!(json["data"][0]["object"], "embedding");
assert_eq!(json["data"][0]["index"], 0);
assert_eq!(json["data"][0]["embedding"][0], 1.0);
assert_eq!(json["model"], "text-emb");
assert!(json["usage"]["prompt_tokens"].as_u64().unwrap() > 0);
}
#[tokio::test]
async fn proxy_embeddings_accepts_batch() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom(
"fake",
Arc::new(EmbedMockProvider {
captured: Arc::new(Mutex::new(None)),
}),
)
.await;
let app = router(llm);
let resp = app
.oneshot(embed_body("fake/m", serde_json::json!(["a", "b", "c"])))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["data"].as_array().unwrap().len(), 3);
assert_eq!(json["data"][0]["index"], 0);
assert_eq!(json["data"][2]["index"], 2);
}
#[tokio::test]
async fn proxy_embeddings_forwards_dimensions_and_user() {
let llm = Arc::new(LmrsClient::new());
let captured = Arc::new(Mutex::new(None));
llm.set_custom(
"fake",
Arc::new(EmbedMockProvider {
captured: Arc::clone(&captured),
}),
)
.await;
let app = router(llm);
let body = serde_json::json!({
"model": "fake/m",
"input": "hi",
"dimensions": 1024,
"user": "user-1"
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/embeddings")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = captured.lock().unwrap();
let req = req.as_ref().unwrap();
assert_eq!(req.dimensions, Some(1024));
assert_eq!(req.user.as_deref(), Some("user-1"));
}
#[tokio::test]
async fn proxy_embeddings_rejects_base64_encoding_format() {
let llm = Arc::new(LmrsClient::new());
let app = router(llm);
let body = serde_json::json!({
"model": "fake/m",
"input": "hi",
"encoding_format": "base64"
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/embeddings")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"]["type"], "invalid_request_error");
assert!(json["error"]["message"]
.as_str()
.unwrap()
.contains("encoding_format"));
}
#[tokio::test]
async fn proxy_embeddings_rejects_empty_input_array() {
let llm = Arc::new(LmrsClient::new());
let app = router(llm);
let body = serde_json::json!({
"model": "fake/m",
"input": []
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/embeddings")
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn proxy_embeddings_unknown_provider_maps_404() {
let llm = Arc::new(LmrsClient::new());
let app = router(llm);
let resp = app
.oneshot(embed_body("missing/text-emb", serde_json::json!("hi")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"]["type"], "invalid_request_error");
}
#[tokio::test]
async fn proxy_embeddings_unsupported_maps_invalid_request() {
struct ChatOnly;
#[async_trait]
impl Provider for ChatOnly {
async fn chat(&self, _: &ChatRequest) -> Result<ChatResponse> {
Ok(ChatResponse::default())
}
async fn stream(
&self,
_: &ChatRequest,
) -> Result<BoxStream<'static, Result<StreamChunk>>> {
Ok(Box::pin(stream::empty()))
}
}
let llm = Arc::new(LmrsClient::new());
llm.set_custom("no-emb", Arc::new(ChatOnly)).await;
let app = router(llm);
let resp = app
.oneshot(embed_body("no-emb/m", serde_json::json!("hi")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"]["type"], "invalid_request_error");
assert!(json["error"]["message"]
.as_str()
.unwrap()
.to_lowercase()
.contains("unsupported"));
}
#[tokio::test]
async fn proxy_embeddings_auth_required_with_router_with_auth() {
let llm = Arc::new(LmrsClient::new());
llm.set_custom(
"fake",
Arc::new(EmbedMockProvider {
captured: Arc::new(Mutex::new(None)),
}),
)
.await;
let app = router_with_auth(llm, "secret".into());
let resp = app
.clone()
.oneshot(embed_body("fake/m", serde_json::json!("hi")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/embeddings")
.header("content-type", "application/json")
.header("authorization", "Bearer secret")
.body(Body::from(
serde_json::json!({"model":"fake/m","input":"hi"}).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn proxy_embeddings_invalid_json_returns_json_error() {
let llm = Arc::new(LmrsClient::new());
let app = router(llm);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/v1/embeddings")
.header("content-type", "application/json")
.body(Body::from("not json"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["error"]["type"], "invalid_request_error");
}
#[tokio::test]
async fn proxy_embeddings_model_prefix_is_stripped() {
let llm = Arc::new(LmrsClient::new());
let captured = Arc::new(Mutex::new(None));
llm.set_custom(
"fake",
Arc::new(EmbedMockProvider {
captured: Arc::clone(&captured),
}),
)
.await;
let app = router(llm);
let resp = app
.oneshot(embed_body("fake/text-embedding", serde_json::json!("hi")))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = captured.lock().unwrap();
assert_eq!(req.as_ref().unwrap().model, "text-embedding");
}
}