use async_trait::async_trait;
use futures::{stream::BoxStream, StreamExt};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::providers::http::{build_http_client, REQUEST_TIMEOUT};
use crate::providers::stream_util::line_stream;
use crate::providers::{LlmError, Provider, ProviderConfig, Result};
use crate::types::{
ChatRequest, ChatResponse, Content, Embedding, EmbeddingRequest, EmbeddingResponse,
EmbeddingUsage, FinishReason, FunctionCall, LogProbs, Message, ResponseFormat, StreamChunk,
Tool, ToolCall, ToolChoice, Usage,
};
use std::collections::HashMap;
#[derive(Serialize)]
struct CompChatRequest<'a> {
model: &'a str,
messages: &'a [CompMessage],
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
top_p: Option<f64>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
stream_options: Option<StreamOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<&'a [Tool]>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_choice: Option<&'a ToolChoice>,
#[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<&'a ResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
stop: Option<&'a [String]>,
#[serde(skip_serializing_if = "Option::is_none")]
n: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
seed: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
presence_penalty: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
frequency_penalty: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
logprobs: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
top_logprobs: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
parallel_tool_calls: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
service_tier: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
store: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
metadata: Option<&'a serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<&'a str>,
#[serde(flatten)]
extra: &'a HashMap<String, serde_json::Value>,
}
#[derive(Serialize)]
struct StreamOptions {
include_usage: bool,
}
#[derive(Serialize, Deserialize)]
struct CompMessage {
role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<Content>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<ToolCall>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
impl From<&Message> for CompMessage {
fn from(msg: &Message) -> Self {
let role = match msg.role {
crate::types::Role::System => "system",
crate::types::Role::User => "user",
crate::types::Role::Assistant => "assistant",
crate::types::Role::Tool => "tool",
}
.to_string();
let content = if msg.content.is_empty() && msg.tool_calls.is_some() {
None
} else {
Some(msg.content.clone())
};
Self {
role,
content,
tool_calls: msg.tool_calls.clone(),
tool_call_id: msg.tool_call_id.clone(),
name: msg.name.clone(),
}
}
}
#[derive(Deserialize)]
struct CompResponse {
choices: Vec<CompChoice>,
model: String,
usage: Option<CompUsage>,
}
#[derive(Deserialize)]
struct CompChoice {
message: CompMessage,
#[serde(default)]
finish_reason: Option<String>,
#[serde(default)]
logprobs: Option<LogProbs>,
}
#[derive(Deserialize)]
struct CompUsage {
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
}
#[derive(Deserialize)]
struct CompStreamChunk {
#[serde(default)]
choices: Vec<CompStreamChoice>,
#[serde(default)]
usage: Option<CompUsage>,
}
#[derive(Deserialize)]
struct CompStreamChoice {
delta: CompDelta,
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct CompDelta {
content: Option<String>,
#[serde(default)]
tool_calls: Option<Vec<CompToolCallDelta>>,
}
#[derive(Deserialize)]
struct CompToolCallDelta {
#[serde(default)]
index: usize,
#[serde(default)]
id: Option<String>,
#[serde(default)]
function: Option<CompFunctionDelta>,
}
#[derive(Deserialize)]
struct CompFunctionDelta {
#[serde(default)]
name: Option<String>,
#[serde(default)]
arguments: Option<String>,
}
#[derive(Deserialize)]
struct CompErrorBody {
error: CompErrorDetail,
}
#[derive(Deserialize)]
struct CompErrorDetail {
message: String,
}
#[derive(Serialize)]
struct CompEmbeddingRequest<'a> {
model: &'a str,
input: &'a [String],
#[serde(skip_serializing_if = "Option::is_none")]
dimensions: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<&'a str>,
#[serde(flatten)]
extra: &'a HashMap<String, serde_json::Value>,
}
#[derive(Deserialize)]
struct CompEmbeddingResponse {
#[serde(default)]
model: Option<String>,
data: Vec<CompEmbedding>,
#[serde(default)]
usage: Option<CompEmbeddingUsage>,
}
#[derive(Deserialize)]
struct CompEmbedding {
index: usize,
embedding: Vec<f32>,
}
#[derive(Deserialize)]
struct CompEmbeddingUsage {
prompt_tokens: u64,
total_tokens: u64,
}
#[derive(Default)]
struct ToolCallAccumulator {
builders: Vec<ToolCallBuilder>,
}
#[derive(Default)]
struct ToolCallBuilder {
id: String,
name: String,
arguments: String,
}
impl ToolCallAccumulator {
fn ingest(&mut self, deltas: &[CompToolCallDelta]) {
for delta in deltas {
if self.builders.len() <= delta.index {
self.builders
.resize_with(delta.index + 1, ToolCallBuilder::default);
}
let builder = &mut self.builders[delta.index];
if let Some(id) = delta.id.as_deref().filter(|s| !s.is_empty()) {
builder.id = id.to_string();
}
if let Some(function) = &delta.function {
if let Some(name) = function.name.as_deref().filter(|s| !s.is_empty()) {
builder.name = name.to_string();
}
if let Some(arguments) = &function.arguments {
builder.arguments.push_str(arguments);
}
}
}
}
fn take(&mut self) -> Option<Vec<ToolCall>> {
if self.builders.is_empty() {
return None;
}
let calls: Vec<ToolCall> = self
.builders
.drain(..)
.filter(|b| !b.name.is_empty())
.map(|b| ToolCall {
id: b.id,
call_type: "function".to_string(),
function: FunctionCall {
name: b.name,
arguments: b.arguments,
},
})
.collect();
if calls.is_empty() {
None
} else {
Some(calls)
}
}
}
fn parse_sse_line(tools: &mut ToolCallAccumulator, line: &str) -> Vec<Result<StreamChunk>> {
let line = line.trim();
let Some(data) = line.strip_prefix("data: ") else {
return Vec::new();
};
if data == "[DONE]" {
return vec![Ok(StreamChunk {
done: true,
..Default::default()
})];
}
let parsed = match serde_json::from_str::<CompStreamChunk>(data) {
Ok(p) => p,
Err(e) => {
return vec![Err(LlmError::Parse(format!(
"failed to parse OpenAI-compatible stream chunk: {e}"
)))];
}
};
let usage = parsed.usage.map(|u| Usage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
});
match parsed.choices.first() {
Some(choice) => {
if let Some(deltas) = &choice.delta.tool_calls {
tools.ingest(deltas);
}
let finish_reason = choice.finish_reason.clone().map(FinishReason::from);
let tool_calls = if finish_reason.is_some() {
tools.take()
} else {
None
};
vec![Ok(StreamChunk {
delta: choice.delta.content.clone().unwrap_or_default(),
done: finish_reason.is_some(),
finish_reason,
usage,
tool_calls,
})]
}
None => {
if usage.is_some() {
vec![Ok(StreamChunk {
usage,
..Default::default()
})]
} else {
Vec::new()
}
}
}
}
pub struct OpenAiCompatibleProvider {
client: Client,
api_key: String,
base_url: String,
extra_headers: Vec<(String, String)>,
}
impl OpenAiCompatibleProvider {
pub fn new(
config: ProviderConfig,
extra_headers: impl IntoIterator<Item = (String, String)>,
) -> Self {
let timeout = config
.timeout_secs
.map(Duration::from_secs)
.or(Some(REQUEST_TIMEOUT));
let base_url = config.base_url.unwrap_or_default();
Self {
client: build_http_client(timeout, config.custom_headers.as_ref()),
api_key: config.api_key,
base_url,
extra_headers: extra_headers.into_iter().collect(),
}
}
async fn send(&self, body: &CompChatRequest<'_>) -> Result<reqwest::Response> {
let mut rb = self
.client
.post(format!("{}/chat/completions", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key));
for (k, v) in &self.extra_headers {
rb = rb.header(k.as_str(), v.as_str());
}
let resp = rb.json(body).send().await?;
Ok(resp)
}
async fn parse_error(resp: reqwest::Response) -> LlmError {
let status = resp.status().as_u16();
let text = resp.text().await.unwrap_or_default();
let msg = serde_json::from_str::<CompErrorBody>(&text)
.map(|e| e.error.message)
.unwrap_or(text);
LlmError::Api {
status,
message: msg,
}
}
async fn send_embed(&self, body: &CompEmbeddingRequest<'_>) -> Result<reqwest::Response> {
let mut rb = self
.client
.post(format!("{}/embeddings", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key));
for (k, v) in &self.extra_headers {
rb = rb.header(k.as_str(), v.as_str());
}
let resp = rb.json(body).send().await?;
Ok(resp)
}
fn parse_response(parsed: CompResponse) -> ChatResponse {
let usage = parsed.usage.map(|u| Usage {
prompt_tokens: u.prompt_tokens,
completion_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
});
let (content, tool_calls, finish_reason, logprobs) = match parsed.choices.into_iter().next()
{
Some(choice) => {
let content = match choice.message.content {
Some(c) => c.as_text(),
None => String::new(),
};
(
content,
choice.message.tool_calls,
choice.finish_reason.map(FinishReason::from),
choice.logprobs,
)
}
None => (String::new(), None, None, None),
};
ChatResponse {
content,
model: parsed.model,
usage,
tool_calls,
finish_reason,
logprobs,
}
}
}
#[async_trait]
impl Provider for OpenAiCompatibleProvider {
async fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
crate::providers::warn_if_unsupported_n("openai-compatible", req.n);
let messages: Vec<CompMessage> = req.messages.iter().map(CompMessage::from).collect();
let body = CompChatRequest {
model: &req.model,
messages: &messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
top_p: req.top_p,
stream: false,
stream_options: None,
tools: req.tools.as_deref(),
tool_choice: req.tool_choice.as_ref(),
response_format: req.response_format.as_ref(),
stop: req.stop.as_deref(),
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.as_deref(),
store: req.store,
metadata: req.metadata.as_ref(),
user: req.user.as_deref(),
extra: &req.extra,
};
tracing::debug!(
provider = "openai-compatible",
model = &req.model,
"sending chat request"
);
let resp = self.send(&body).await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let err = Self::parse_error(resp).await;
tracing::error!(
provider = "openai-compatible",
status,
error_kind = "api_error",
"API error"
);
return Err(err);
}
let parsed: CompResponse = resp
.json()
.await
.map_err(|e| LlmError::Parse(format!("OpenAI-compatible parse: {e}")))?;
let result = Self::parse_response(parsed);
tracing::debug!(provider = "openai-compatible", model = &result.model, finish_reason = ?result.finish_reason, "chat response received");
Ok(result)
}
async fn stream(&self, req: &ChatRequest) -> Result<BoxStream<'static, Result<StreamChunk>>> {
crate::providers::warn_if_unsupported_n("openai-compatible", req.n);
let messages: Vec<CompMessage> = req.messages.iter().map(CompMessage::from).collect();
let body = CompChatRequest {
model: &req.model,
messages: &messages,
temperature: req.temperature,
max_tokens: req.max_tokens,
top_p: req.top_p,
stream: true,
stream_options: Some(StreamOptions {
include_usage: true,
}),
tools: req.tools.as_deref(),
tool_choice: req.tool_choice.as_ref(),
response_format: req.response_format.as_ref(),
stop: req.stop.as_deref(),
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.as_deref(),
store: req.store,
metadata: req.metadata.as_ref(),
user: req.user.as_deref(),
extra: &req.extra,
};
tracing::debug!(
provider = "openai-compatible",
model = &req.model,
"sending stream request"
);
let resp = self.send(&body).await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let err = Self::parse_error(resp).await;
tracing::error!(
provider = "openai-compatible",
status,
error_kind = "api_error",
"API error"
);
return Err(err);
}
let byte_stream = resp
.bytes_stream()
.map(|r| r.map_err(|e| LlmError::Stream(e.to_string())));
let stream = line_stream(byte_stream)
.scan(ToolCallAccumulator::default(), |tools, line_result| {
let chunks = match line_result {
Ok(line) => parse_sse_line(tools, &line),
Err(e) => vec![Err(e)],
};
futures::future::ready(Some(futures::stream::iter(chunks)))
})
.flatten();
Ok(stream.boxed())
}
async fn embed(&self, req: &EmbeddingRequest) -> Result<EmbeddingResponse> {
tracing::debug!(
provider = "openai-compatible",
model = %req.model,
input_count = req.input.len(),
"sending embedding request"
);
let body = CompEmbeddingRequest {
model: &req.model,
input: &req.input,
dimensions: req.dimensions,
user: req.user.as_deref(),
extra: &req.extra,
};
let resp = self.send_embed(&body).await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let err = Self::parse_error(resp).await;
tracing::error!(
provider = "openai-compatible",
status,
error_kind = "api_error",
"embedding API error"
);
return Err(err);
}
let parsed: CompEmbeddingResponse = resp
.json()
.await
.map_err(|e| LlmError::Parse(format!("OpenAI-compatible embeddings parse: {e}")))?;
let data: Vec<Embedding> = parsed
.data
.into_iter()
.map(|d| Embedding {
index: d.index,
embedding: d.embedding,
})
.collect();
let usage = parsed.usage.map(|u| EmbeddingUsage {
prompt_tokens: u.prompt_tokens,
total_tokens: u.total_tokens,
});
let model = parsed.model.unwrap_or_else(|| req.model.clone());
let result = EmbeddingResponse { model, data, usage };
tracing::debug!(
provider = "openai-compatible",
model = %result.model,
embedding_count = result.data.len(),
"embedding response received"
);
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ContentPart, FunctionCall};
#[test]
fn serializes_tools_and_tool_choice() {
let tools = vec![Tool::function(
"get_weather",
Some("Get weather".to_string()),
serde_json::json!({"type": "object"}),
)];
let choice = ToolChoice::auto();
let messages = vec![CompMessage::from(&Message::user("hi"))];
let body = CompChatRequest {
model: "gpt-4o",
messages: &messages,
temperature: None,
max_tokens: None,
top_p: None,
stream: false,
stream_options: None,
tools: Some(tools.as_slice()),
tool_choice: Some(&choice),
response_format: None,
stop: None,
n: None,
seed: None,
presence_penalty: None,
frequency_penalty: None,
logprobs: None,
top_logprobs: None,
parallel_tool_calls: None,
service_tier: None,
store: None,
metadata: None,
user: None,
extra: &HashMap::new(),
};
let v = serde_json::to_value(&body).unwrap();
assert_eq!(v["tools"][0]["type"], "function");
assert_eq!(v["tools"][0]["function"]["name"], "get_weather");
assert_eq!(v["tool_choice"], "auto");
}
#[test]
fn parses_tool_calls_from_response() {
let raw = serde_json::json!({
"model": "gpt-4o",
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"}
}]
},
"finish_reason": "tool_calls"
}]
})
.to_string();
let parsed: CompResponse = serde_json::from_str(&raw).unwrap();
let resp = OpenAiCompatibleProvider::parse_response(parsed);
assert_eq!(resp.content, "");
assert_eq!(resp.finish_reason, Some(FinishReason::ToolCalls));
let calls = resp.tool_calls.expect("tool_calls present");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].function.name, "get_weather");
}
#[test]
fn parses_logprobs_from_response() {
let raw = serde_json::json!({
"model": "gpt-4o",
"choices": [{
"message": {
"role": "assistant",
"content": "Hi"
},
"finish_reason": "stop",
"logprobs": {
"content": [{
"token": "Hi",
"logprob": -0.25,
"bytes": [72, 105],
"top_logprobs": [{
"token": "Hello",
"logprob": -0.5,
"bytes": [72, 101, 108, 108, 111]
}]
}]
}
}]
})
.to_string();
let parsed: CompResponse = serde_json::from_str(&raw).unwrap();
let resp = OpenAiCompatibleProvider::parse_response(parsed);
assert_eq!(resp.content, "Hi");
assert_eq!(resp.finish_reason, Some(FinishReason::Stop));
let logprobs = resp.logprobs.expect("logprobs present");
assert_eq!(logprobs.content.len(), 1);
assert_eq!(logprobs.content[0].token, "Hi");
assert_eq!(logprobs.content[0].logprob, -0.25);
assert_eq!(logprobs.content[0].bytes, Some(vec![72, 105]));
assert_eq!(logprobs.content[0].top_logprobs.len(), 1);
assert_eq!(logprobs.content[0].top_logprobs[0].token, "Hello");
assert_eq!(logprobs.content[0].top_logprobs[0].logprob, -0.5);
}
#[test]
fn tool_message_serializes_with_id() {
let comp = CompMessage::from(&Message::tool("call_1", "result"));
let v = serde_json::to_value(&comp).unwrap();
assert_eq!(v["role"], "tool");
assert_eq!(v["content"], "result");
assert_eq!(v["tool_call_id"], "call_1");
}
#[test]
fn assistant_tool_call_message_omits_empty_content() {
let msg = Message::assistant_tool_calls(vec![ToolCall {
id: "call_1".to_string(),
call_type: "function".to_string(),
function: FunctionCall {
name: "f".to_string(),
arguments: "{}".to_string(),
},
}]);
let comp = CompMessage::from(&msg);
let v = serde_json::to_value(&comp).unwrap();
assert!(v.get("content").is_none(), "content should be omitted");
assert_eq!(v["tool_calls"][0]["id"], "call_1");
}
#[test]
fn user_image_message_serializes_as_content_parts() {
let msg = Message::user_with_parts(vec![
ContentPart::text("what is this?"),
ContentPart::image_url("https://example.com/cat.png"),
]);
let comp = CompMessage::from(&msg);
let v = serde_json::to_value(&comp).unwrap();
assert_eq!(v["role"], "user");
assert_eq!(v["content"][0]["type"], "text");
assert_eq!(v["content"][0]["text"], "what is this?");
assert_eq!(v["content"][1]["type"], "image_url");
assert_eq!(
v["content"][1]["image_url"]["url"],
"https://example.com/cat.png"
);
}
#[test]
fn serializes_sampling_params_and_response_format() {
let messages = vec![CompMessage::from(&Message::user("hi"))];
let rf = ResponseFormat::json_object();
let stop = vec!["STOP".to_string()];
let metadata = serde_json::json!({"trace_id": "abc"});
let body = CompChatRequest {
model: "gpt-4o",
messages: &messages,
temperature: Some(0.2),
max_tokens: Some(64),
top_p: Some(0.9),
stream: false,
stream_options: None,
tools: None,
tool_choice: None,
response_format: Some(&rf),
stop: Some(stop.as_slice()),
n: Some(2),
seed: Some(7),
presence_penalty: Some(0.5),
frequency_penalty: Some(-0.25),
logprobs: Some(true),
top_logprobs: Some(3),
parallel_tool_calls: Some(false),
service_tier: Some("flex"),
store: Some(true),
metadata: Some(&metadata),
user: Some("user-123"),
extra: &HashMap::new(),
};
let v = serde_json::to_value(&body).unwrap();
assert_eq!(v["response_format"]["type"], "json_object");
assert_eq!(v["stop"][0], "STOP");
assert_eq!(v["n"], 2);
assert_eq!(v["seed"], 7);
assert_eq!(v["presence_penalty"], 0.5);
assert_eq!(v["frequency_penalty"], -0.25);
assert_eq!(v["logprobs"], true);
assert_eq!(v["top_logprobs"], 3);
assert_eq!(v["parallel_tool_calls"], false);
assert_eq!(v["service_tier"], "flex");
assert_eq!(v["store"], true);
assert_eq!(v["metadata"]["trace_id"], "abc");
assert_eq!(v["user"], "user-123");
}
#[test]
fn omits_unset_sampling_params() {
let messages = vec![CompMessage::from(&Message::user("hi"))];
let body = CompChatRequest {
model: "gpt-4o",
messages: &messages,
temperature: None,
max_tokens: None,
top_p: None,
stream: false,
stream_options: None,
tools: None,
tool_choice: None,
response_format: None,
stop: None,
n: None,
seed: None,
presence_penalty: None,
frequency_penalty: None,
logprobs: None,
top_logprobs: None,
parallel_tool_calls: None,
service_tier: None,
store: None,
metadata: None,
user: None,
extra: &HashMap::new(),
};
let v = serde_json::to_value(&body).unwrap();
assert!(v.get("response_format").is_none());
assert!(v.get("stop").is_none());
assert!(v.get("seed").is_none());
assert!(v.get("n").is_none());
assert!(v.get("presence_penalty").is_none());
assert!(v.get("frequency_penalty").is_none());
assert!(v.get("logprobs").is_none());
assert!(v.get("top_logprobs").is_none());
assert!(v.get("parallel_tool_calls").is_none());
assert!(v.get("service_tier").is_none());
assert!(v.get("store").is_none());
assert!(v.get("metadata").is_none());
assert!(v.get("user").is_none());
}
#[test]
fn stream_accumulates_tool_call_fragments() {
let mut tools = ToolCallAccumulator::default();
let lines = [
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"ci"}}]},"finish_reason":null}]}"#,
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ty\":\"SF\"}"}}]},"finish_reason":null}]}"#,
r#"data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}"#,
];
let mut final_chunk = None;
for line in lines {
for chunk in parse_sse_line(&mut tools, line) {
final_chunk = Some(chunk.unwrap());
}
}
let chunk = final_chunk.expect("a terminal chunk");
assert!(chunk.done);
assert_eq!(chunk.finish_reason, Some(FinishReason::ToolCalls));
let calls = chunk.tool_calls.expect("tool calls present");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].id, "call_1");
assert_eq!(calls[0].function.name, "get_weather");
assert_eq!(calls[0].function.arguments, "{\"city\":\"SF\"}");
}
#[test]
fn stream_without_tool_calls_has_none() {
let mut tools = ToolCallAccumulator::default();
let chunks = parse_sse_line(
&mut tools,
r#"data: {"choices":[{"delta":{"content":"hi"},"finish_reason":"stop"}]}"#,
);
let chunk = chunks.into_iter().next().unwrap().unwrap();
assert_eq!(chunk.delta, "hi");
assert!(chunk.done);
assert!(chunk.tool_calls.is_none());
}
#[test]
fn compat_stream_malformed_data_returns_parse_error() {
let mut tools = ToolCallAccumulator::default();
let chunks = parse_sse_line(&mut tools, "data: {not valid json}");
let err = chunks.into_iter().next().unwrap().unwrap_err();
assert!(matches!(err, LlmError::Parse(_)));
assert!(err.to_string().contains("stream chunk"));
}
#[test]
fn compat_stream_ignores_non_data_lines() {
let mut tools = ToolCallAccumulator::default();
let chunks = parse_sse_line(&mut tools, ": keep-alive");
assert!(chunks.is_empty());
}
#[test]
fn compat_stream_done_still_returns_done_chunk() {
let mut tools = ToolCallAccumulator::default();
let chunks = parse_sse_line(&mut tools, "data: [DONE]");
let chunk = chunks.into_iter().next().unwrap().unwrap();
assert!(chunk.done);
}
use std::sync::{Arc, Mutex};
#[test]
fn embed_request_serializes_correctly() {
let req = EmbeddingRequest::new("model", "hi")
.with_dimensions(1024)
.with_user("user-1")
.with_extra("task", "search");
let body = CompEmbeddingRequest {
model: &req.model,
input: &req.input,
dimensions: req.dimensions,
user: req.user.as_deref(),
extra: &req.extra,
};
let v = serde_json::to_value(&body).expect("embedding request should serialize");
assert_eq!(v["model"], "model");
assert_eq!(v["input"][0], "hi");
assert_eq!(v["dimensions"], 1024);
assert_eq!(v["user"], "user-1");
assert_eq!(v["task"], "search");
}
#[test]
fn embed_response_parses_correctly() {
let json = r#"{"model":"text-embedding-3-small","data":[{"index":0,"embedding":[0.1,0.2]},{"index":1,"embedding":[0.3,0.4]}],"usage":{"prompt_tokens":5,"total_tokens":5}}"#;
let parsed: CompEmbeddingResponse =
serde_json::from_str(json).expect("embedding response should parse");
assert_eq!(parsed.model.as_deref(), Some("text-embedding-3-small"));
assert_eq!(parsed.data.len(), 2);
assert_eq!(parsed.data[0].index, 0);
assert_eq!(parsed.data[0].embedding, vec![0.1_f32, 0.2]);
assert_eq!(parsed.data[1].index, 1);
assert_eq!(parsed.data[1].embedding, vec![0.3_f32, 0.4]);
let usage = parsed.usage.expect("usage present");
assert_eq!(usage.prompt_tokens, 5);
assert_eq!(usage.total_tokens, 5);
}
#[test]
fn embed_response_falls_back_to_request_model() {
let json = r#"{"data":[]}"#;
let parsed: CompEmbeddingResponse =
serde_json::from_str(json).expect("should parse without model");
assert!(parsed.model.is_none());
let resp = EmbeddingResponse {
model: parsed.model.unwrap_or_else(|| "fallback".to_string()),
data: vec![],
usage: None,
};
assert_eq!(resp.model, "fallback");
}
#[test]
fn embed_error_response_parses_message() {
let json = r#"{"error":{"message":"bad input"}}"#;
let parsed: CompErrorBody = serde_json::from_str(json).expect("error body should parse");
assert!(parsed.error.message.contains("bad input"));
}
#[tokio::test]
async fn openai_compatible_embed_via_lmrs_client_strips_prefix() {
use crate::LmrsClient;
let llm = LmrsClient::new();
let seen: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
let seen_clone = Arc::clone(&seen);
struct CapturingEmbedProvider {
seen: Arc<Mutex<Option<String>>>,
}
#[async_trait]
impl Provider for CapturingEmbedProvider {
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.seen.lock().unwrap() = Some(req.model.clone());
Ok(EmbeddingResponse {
model: req.model.clone(),
data: vec![],
usage: None,
})
}
}
llm.set_custom(
"openai",
Arc::new(CapturingEmbedProvider { seen: seen_clone }),
)
.await;
llm.embed("openai/text-embedding-3-small", "hello")
.await
.expect("embed should succeed via LmrsClient");
assert_eq!(
seen.lock().unwrap().as_deref(),
Some("text-embedding-3-small")
);
}
#[cfg(feature = "proxy")]
#[tokio::test]
async fn openai_compatible_embed_posts_to_embeddings_endpoint() {
let embed_ok = axum::Router::new().route(
"/embeddings",
axum::routing::post(
|headers: axum::http::HeaderMap,
axum::Json(body): axum::Json<serde_json::Value>| async move {
assert!(headers.get("authorization").is_some());
assert_eq!(body["model"], "text-embedding-3-small");
assert_eq!(body["input"][0], "hello");
assert_eq!(body["input"][1], "world");
axum::Json(serde_json::json!({"data":[],"model":"m"}))
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move { axum::serve(listener, embed_ok).await.unwrap() });
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let config = ProviderConfig::new("sk-test").with_base_url(&url);
let provider = OpenAiCompatibleProvider::new(config, []);
let req = EmbeddingRequest::batch("text-embedding-3-small", ["hello", "world"]);
provider.embed(&req).await.expect("embed should succeed");
}
#[cfg(feature = "proxy")]
#[tokio::test]
async fn openai_compatible_embed_sends_dimensions_user_and_extra() {
let embed_ok = axum::Router::new().route(
"/embeddings",
axum::routing::post(
|axum::Json(body): axum::Json<serde_json::Value>| async move {
assert_eq!(body["dimensions"], 1024);
assert_eq!(body["user"], "u");
assert_eq!(body["task"], "search");
axum::Json(serde_json::json!({"data":[],"model":"m"}))
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move { axum::serve(listener, embed_ok).await.unwrap() });
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let config = ProviderConfig::new("sk-test").with_base_url(&url);
let provider = OpenAiCompatibleProvider::new(config, []);
let req = EmbeddingRequest::new("m", "hi")
.with_dimensions(1024)
.with_user("u")
.with_extra("task", "search");
provider.embed(&req).await.expect("embed should succeed");
}
#[cfg(feature = "proxy")]
#[tokio::test]
async fn openai_compatible_embed_maps_api_error() {
let embed_400 = axum::Router::new().route(
"/embeddings",
axum::routing::post(|| async {
(
axum::http::StatusCode::BAD_REQUEST,
axum::Json(serde_json::json!({"error":{"message":"bad input"}})),
)
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move { axum::serve(listener, embed_400).await.unwrap() });
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let config = ProviderConfig::new("sk-test").with_base_url(&url);
let provider = OpenAiCompatibleProvider::new(config, []);
let req = EmbeddingRequest::new("m", "hi");
let err = provider.embed(&req).await.unwrap_err();
assert!(
matches!(&err, LlmError::Api { status: 400, message } if message.contains("bad input")),
"expected Api 400, got: {err:?}"
);
}
#[cfg(feature = "proxy")]
#[tokio::test]
async fn openai_provider_embed_strips_prefix_with_real_wrapper() {
use crate::providers::openai::OpenAIProvider;
let embed_ok = axum::Router::new().route(
"/embeddings",
axum::routing::post(
|axum::Json(body): axum::Json<serde_json::Value>| async move {
assert_eq!(body["model"], "text-embedding-3-small");
axum::Json(serde_json::json!({"data":[],"model":"text-embedding-3-small"}))
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
tokio::spawn(async move { axum::serve(listener, embed_ok).await.unwrap() });
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
let config = ProviderConfig::new("sk-test").with_base_url(&url);
let provider: Arc<dyn Provider> = Arc::new(OpenAIProvider::new(config));
use crate::LmrsClient;
let llm = LmrsClient::new();
llm.set_custom("openai", provider).await;
llm.embed("openai/text-embedding-3-small", "hello")
.await
.expect("embed should succeed via real OpenAIProvider");
}
}