use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use daimon_core::stream_util::{LineBuffer, backoff_delay, parse_retry_after_secs};
use daimon_core::{
ChatResponse, DaimonError, Message, ResponseStream, Result, Role, StopReason, StreamEvent,
ToolCall, ToolSpec, Usage,
};
pub(crate) const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
pub(crate) const DEFAULT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
pub(crate) const DEFAULT_MAX_RETRIES: u32 = 3;
pub(crate) fn build_client() -> Client {
Client::builder()
.connect_timeout(DEFAULT_CONNECT_TIMEOUT)
.build()
.expect("failed to build HTTP client")
}
pub(crate) struct Http {
client: Client,
base_url: String,
api_key: Option<String>,
timeout: Option<Duration>,
max_retries: u32,
allow_plaintext_api_key: bool,
warned_plaintext_key: AtomicBool,
}
impl Http {
pub(crate) fn new(default_base_url: &str) -> Self {
Self {
client: build_client(),
base_url: default_base_url.to_string(),
api_key: None,
timeout: None,
max_retries: DEFAULT_MAX_RETRIES,
allow_plaintext_api_key: false,
warned_plaintext_key: AtomicBool::new(false),
}
}
pub(crate) fn set_base_url(&mut self, url: impl Into<String>) {
self.base_url = url.into().trim_end_matches('/').to_string();
}
pub(crate) fn set_api_key(&mut self, key: impl Into<String>) {
self.api_key = Some(key.into());
}
pub(crate) fn set_timeout(&mut self, timeout: Duration) {
self.timeout = Some(timeout);
}
pub(crate) fn set_max_retries(&mut self, retries: u32) {
self.max_retries = retries;
}
pub(crate) fn set_allow_plaintext_api_key(&mut self, allow: bool) {
self.allow_plaintext_api_key = allow;
}
#[cfg(test)]
pub(crate) fn base_url(&self) -> &str {
&self.base_url
}
#[cfg(test)]
pub(crate) fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
#[cfg(test)]
pub(crate) fn timeout(&self) -> Option<Duration> {
self.timeout
}
#[cfg(test)]
pub(crate) fn max_retries(&self) -> u32 {
self.max_retries
}
fn check_plaintext_key(&self) -> Result<()> {
if self.api_key.is_none() || !self.base_url.to_ascii_lowercase().starts_with("http://") {
return Ok(());
}
if !self.allow_plaintext_api_key {
return Err(DaimonError::Builder(format!(
"refusing to send API key over plaintext HTTP to {} — use https://, or call \
.allow_plaintext_api_key() to opt into cleartext for a genuinely local, \
unauthenticated-but-keyed server",
self.base_url
)));
}
if !self.warned_plaintext_key.swap(true, Ordering::Relaxed) {
tracing::warn!(
base_url = %self.base_url,
"sending API key over plaintext HTTP to {} (allowed via allow_plaintext_api_key)",
self.base_url
);
}
Ok(())
}
pub(crate) async fn post(
&self,
path: &str,
body: &impl Serialize,
) -> Result<reqwest::Response> {
self.post_with_retry(path, body, self.effective_timeout(false))
.await
}
pub(crate) async fn post_streaming(
&self,
path: &str,
body: &impl Serialize,
) -> Result<reqwest::Response> {
self.post_with_retry(path, body, self.effective_timeout(true))
.await
}
fn effective_timeout(&self, streaming: bool) -> Option<Duration> {
if streaming {
None
} else {
Some(self.timeout.unwrap_or(DEFAULT_REQUEST_TIMEOUT))
}
}
async fn post_with_retry(
&self,
path: &str,
body: &impl Serialize,
timeout: Option<Duration>,
) -> Result<reqwest::Response> {
self.check_plaintext_key()?;
for attempt in 0..=self.max_retries {
let mut req = self
.client
.post(format!("{}{path}", self.base_url))
.json(body);
if let Some(t) = timeout {
req = req.timeout(t);
}
if let Some(key) = &self.api_key {
req = req.bearer_auth(key);
}
let response = match send_with_handshake_bound(req, timeout).await {
Ok(response) => response,
Err(e) => {
if e.is_retryable() && attempt < self.max_retries {
let delay = backoff_delay(attempt, None);
tracing::debug!(
error = %e,
attempt,
delay_ms = delay.as_millis(),
"retryable transport error, backing off"
);
tokio::time::sleep(delay).await;
continue;
}
return Err(DaimonError::Model(format!("HTTP error: {e}")));
}
};
let status = response.status();
let is_retryable = is_retryable_status(status);
if !is_retryable || attempt == self.max_retries {
return Ok(response);
}
let retry_after = response
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(parse_retry_after_secs)
.map(Duration::from_secs);
let delay = backoff_delay(attempt, retry_after);
tracing::debug!(
status = %status,
attempt,
delay_ms = delay.as_millis(),
"retryable HTTP error, backing off"
);
tokio::time::sleep(delay).await;
}
unreachable!("loop always returns")
}
}
pub(crate) fn is_retryable_status(status: reqwest::StatusCode) -> bool {
status.as_u16() == 429 || status.is_server_error()
}
pub(crate) fn is_retryable_transport_error(e: &reqwest::Error) -> bool {
e.is_timeout() || e.is_connect() || e.is_request()
}
enum TransportFailure {
Reqwest(reqwest::Error),
HandshakeTimedOut,
}
impl TransportFailure {
fn is_retryable(&self) -> bool {
match self {
Self::Reqwest(e) => is_retryable_transport_error(e),
Self::HandshakeTimedOut => true,
}
}
}
impl std::fmt::Display for TransportFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Reqwest(e) => write!(f, "{e}"),
Self::HandshakeTimedOut => {
write!(
f,
"streaming handshake exceeded {DEFAULT_HANDSHAKE_TIMEOUT:?} without a response"
)
}
}
}
}
async fn send_with_handshake_bound(
req: reqwest::RequestBuilder,
timeout: Option<Duration>,
) -> std::result::Result<reqwest::Response, TransportFailure> {
if timeout.is_none() {
match tokio::time::timeout(DEFAULT_HANDSHAKE_TIMEOUT, req.send()).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(e)) => Err(TransportFailure::Reqwest(e)),
Err(_) => Err(TransportFailure::HandshakeTimedOut),
}
} else {
req.send().await.map_err(TransportFailure::Reqwest)
}
}
impl std::fmt::Debug for Http {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Http")
.field("base_url", &self.base_url)
.field("api_key", &self.api_key.as_ref().map(|_| "[redacted]"))
.field("timeout", &self.timeout)
.field("max_retries", &self.max_retries)
.finish_non_exhaustive()
}
}
pub(crate) fn api_error(status: reqwest::StatusCode, body: &str, provider: &str) -> DaimonError {
DaimonError::Model(format!("{provider} API error ({status}): {body}"))
}
#[derive(Serialize)]
pub(crate) struct ChatCompletionRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) model: Option<String>,
pub(crate) messages: Vec<ApiMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) tools: Option<Vec<ApiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) max_tokens: Option<u32>,
pub(crate) stream: bool,
#[serde(flatten)]
pub(crate) extra: serde_json::Map<String, serde_json::Value>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_chat_request(
messages: &[Message],
tools: &[ToolSpec],
model: Option<&str>,
temperature: Option<f32>,
max_tokens: Option<u32>,
stream: bool,
extra: serde_json::Map<String, serde_json::Value>,
) -> ChatCompletionRequest {
let api_messages: Vec<ApiMessage> = messages.iter().map(Into::into).collect();
let api_tools: Option<Vec<ApiTool>> = if tools.is_empty() {
None
} else {
Some(tools.iter().map(Into::into).collect())
};
ChatCompletionRequest {
model: model.map(str::to_string),
messages: api_messages,
tools: api_tools,
temperature,
max_tokens,
stream,
extra,
}
}
#[derive(Serialize)]
pub(crate) struct ApiMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<ApiToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
}
impl From<&Message> for ApiMessage {
fn from(msg: &Message) -> Self {
let role = match msg.role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
};
let tool_calls = if msg.tool_calls.is_empty() {
None
} else {
Some(
msg.tool_calls
.iter()
.map(|tc| ApiToolCall {
id: tc.id.clone(),
r#type: "function".to_string(),
function: ApiFunction {
name: tc.name.clone(),
arguments: serde_json::to_string(&tc.arguments).unwrap_or_default(),
},
})
.collect(),
)
};
Self {
role: role.to_string(),
content: msg.content.clone(),
tool_calls,
tool_call_id: msg.tool_call_id.clone(),
}
}
}
#[derive(Serialize)]
pub(crate) struct ApiTool {
r#type: String,
function: ApiToolFunction,
}
impl From<&ToolSpec> for ApiTool {
fn from(spec: &ToolSpec) -> Self {
Self {
r#type: "function".to_string(),
function: ApiToolFunction {
name: spec.name.clone(),
description: spec.description.clone(),
parameters: spec.parameters.clone(),
},
}
}
}
#[derive(Serialize)]
struct ApiToolFunction {
name: String,
description: String,
parameters: serde_json::Value,
}
#[derive(Serialize, Deserialize)]
struct ApiToolCall {
id: String,
r#type: String,
function: ApiFunction,
}
#[derive(Serialize, Deserialize, Default)]
struct ApiFunction {
#[serde(default)]
name: String,
#[serde(default)]
arguments: String,
}
#[derive(Deserialize)]
struct ChatCompletionResponse {
choices: Vec<ApiChoice>,
usage: Option<ApiUsage>,
}
#[derive(Deserialize)]
struct ApiChoice {
message: ApiChoiceMessage,
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct ApiChoiceMessage {
content: Option<String>,
tool_calls: Option<Vec<ApiResponseToolCall>>,
}
#[derive(Deserialize)]
struct ApiResponseToolCall {
#[serde(default)]
id: String,
#[serde(default)]
function: ApiFunction,
}
#[derive(Deserialize)]
struct ApiUsage {
prompt_tokens: u32,
completion_tokens: u32,
}
pub(crate) fn finish_reason_to_stop(reason: Option<&str>) -> StopReason {
match reason {
Some("tool_calls") => StopReason::ToolUse,
Some("length") => StopReason::MaxTokens,
Some("content_filter") => StopReason::ContentFiltered,
_ => StopReason::EndTurn,
}
}
pub(crate) fn parse_chat_response(body: &[u8], provider: &str) -> Result<ChatResponse> {
let parsed: ChatCompletionResponse = serde_json::from_slice(body)
.map_err(|e| DaimonError::Model(format!("{provider} response parse error: {e}")))?;
let choice = parsed
.choices
.into_iter()
.next()
.ok_or_else(|| DaimonError::Model(format!("no choices in {provider} response")))?;
let tool_calls: Vec<ToolCall> = choice
.message
.tool_calls
.unwrap_or_default()
.into_iter()
.map(|tc| {
let arguments = if tc.function.arguments.trim().is_empty() {
serde_json::Value::Null
} else {
serde_json::from_str(&tc.function.arguments).map_err(|e| {
DaimonError::Model(format!(
"{provider} returned malformed tool-call arguments for {:?} (id {:?}): {e}; raw: {:?}",
tc.function.name, tc.id, tc.function.arguments
))
})?
};
Ok(ToolCall {
id: tc.id,
name: tc.function.name,
arguments,
})
})
.collect::<Result<Vec<ToolCall>>>()?;
let stop_reason = finish_reason_to_stop(choice.finish_reason.as_deref());
Ok(ChatResponse {
message: Message {
role: Role::Assistant,
content: choice.message.content,
tool_calls,
tool_call_id: None,
},
stop_reason,
usage: parsed.usage.map(|u| Usage {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
cached_tokens: 0,
}),
})
}
#[derive(Deserialize)]
struct StreamChunk {
choices: Vec<StreamChoice>,
}
#[derive(Deserialize)]
struct StreamChoice {
delta: StreamDelta,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct StreamDelta {
content: Option<String>,
tool_calls: Option<Vec<StreamToolCall>>,
}
#[derive(Deserialize)]
struct StreamToolCall {
#[serde(default)]
index: usize,
function: Option<StreamFunction>,
}
#[derive(Deserialize)]
struct StreamFunction {
name: Option<String>,
arguments: Option<String>,
}
pub(crate) fn sse_line_events_into(line: &str, events: &mut Vec<StreamEvent>) {
let line = line.trim();
if line == "data: [DONE]" {
events.push(StreamEvent::Done);
return;
}
let Some(data) = line.strip_prefix("data: ") else {
return;
};
let Ok(chunk) = serde_json::from_str::<StreamChunk>(data) else {
return;
};
for choice in &chunk.choices {
if let Some(content) = &choice.delta.content
&& !content.is_empty()
{
events.push(StreamEvent::TextDelta(content.clone()));
}
if let Some(tool_calls) = &choice.delta.tool_calls {
for tc in tool_calls {
if let Some(func) = &tc.function {
if let Some(name) = &func.name {
events.push(StreamEvent::ToolCallStart {
id: tc.index.to_string(),
name: name.clone(),
});
}
if let Some(args) = &func.arguments
&& !args.is_empty()
{
events.push(StreamEvent::ToolCallDelta {
id: tc.index.to_string(),
arguments_delta: args.clone(),
});
}
}
}
}
if choice.finish_reason.as_deref() == Some("content_filter") {
events.push(StreamEvent::Error(
"response blocked (finish_reason=content_filter)".to_string(),
));
}
}
}
pub(crate) fn stream_chat_response(
response: reqwest::Response,
provider: &'static str,
) -> ResponseStream {
let stream = async_stream::try_stream! {
use futures::StreamExt;
let mut buffer = LineBuffer::new();
let mut byte_stream = Box::pin(response.bytes_stream());
let mut events: Vec<StreamEvent> = Vec::new();
while let Some(chunk) = byte_stream.next().await {
let chunk = chunk
.map_err(|e| DaimonError::Model(format!("{provider} stream error: {e}")))?;
buffer.push(&chunk);
while let Some(line) = buffer.next_line() {
sse_line_events_into(&line, &mut events);
for event in events.drain(..) {
yield event;
}
}
}
if let Some(line) = buffer.take_remaining() {
sse_line_events_into(&line, &mut events);
for event in events.drain(..) {
yield event;
}
}
};
Box::pin(stream)
}
#[derive(Serialize)]
pub(crate) struct EmbedRequest<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) model: Option<&'a str>,
pub(crate) input: &'a [&'a str],
}
#[derive(Deserialize)]
struct EmbedResponse {
data: Vec<EmbedDatum>,
}
#[derive(Deserialize)]
struct EmbedDatum {
embedding: Vec<f32>,
}
pub(crate) fn parse_embed_response(body: &[u8], provider: &str) -> Result<Vec<Vec<f32>>> {
let parsed: EmbedResponse = serde_json::from_slice(body)
.map_err(|e| DaimonError::Model(format!("{provider} embedding parse error: {e}")))?;
Ok(parsed.data.into_iter().map(|d| d.embedding).collect())
}
#[cfg(test)]
pub(crate) fn sse_line_events(line: &str) -> Vec<StreamEvent> {
let mut events = Vec::new();
sse_line_events_into(line, &mut events);
events
}
#[cfg(test)]
mod tests {
use super::*;
use daimon_core::Message;
#[test]
fn test_finish_reason_mapping() {
assert_eq!(
finish_reason_to_stop(Some("tool_calls")),
StopReason::ToolUse
);
assert_eq!(finish_reason_to_stop(Some("length")), StopReason::MaxTokens);
assert_eq!(
finish_reason_to_stop(Some("content_filter")),
StopReason::ContentFiltered
);
assert_eq!(finish_reason_to_stop(Some("stop")), StopReason::EndTurn);
assert_eq!(finish_reason_to_stop(None), StopReason::EndTurn);
}
#[test]
fn test_build_chat_request_basic() {
let messages = vec![Message::user("hi")];
let req = build_chat_request(&messages, &[], None, None, None, false, Default::default());
let value = serde_json::to_value(&req).unwrap();
assert_eq!(value["messages"][0]["role"], "user");
assert_eq!(value["messages"][0]["content"], "hi");
assert_eq!(value["stream"], false);
assert!(value.as_object().unwrap().get("model").is_none());
}
#[test]
fn test_build_chat_request_with_extra_fields() {
let mut extra = serde_json::Map::new();
extra.insert("grammar".to_string(), serde_json::json!("root ::= \"yes\""));
let messages = vec![Message::user("hi")];
let req = build_chat_request(&messages, &[], Some("qwen3"), None, None, false, extra);
let value = serde_json::to_value(&req).unwrap();
assert_eq!(value["model"], "qwen3");
assert_eq!(value["grammar"], "root ::= \"yes\"");
}
#[test]
fn test_parse_chat_response_end_turn() {
let body = br#"{
"choices": [{
"message": {"role": "assistant", "content": "hello"},
"finish_reason": "stop"
}],
"usage": {"prompt_tokens": 10, "completion_tokens": 5}
}"#;
let resp = parse_chat_response(body, "test").unwrap();
assert_eq!(resp.message.content.as_deref(), Some("hello"));
assert_eq!(resp.stop_reason, StopReason::EndTurn);
let usage = resp.usage.unwrap();
assert_eq!(usage.input_tokens, 10);
assert_eq!(usage.output_tokens, 5);
}
#[test]
fn test_parse_chat_response_content_filter() {
let body =
br#"{"choices": [{"message": {"content": null}, "finish_reason": "content_filter"}]}"#;
let resp = parse_chat_response(body, "test").unwrap();
assert_eq!(resp.stop_reason, StopReason::ContentFiltered);
}
#[test]
fn test_parse_chat_response_no_choices() {
let body = br#"{"choices": []}"#;
assert!(parse_chat_response(body, "test").is_err());
}
#[test]
fn test_parse_chat_response_valid_tool_call_args() {
let body = br#"{
"choices": [{
"message": {
"content": null,
"tool_calls": [{"id": "call_1", "function": {"name": "calc", "arguments": "{\"x\": 1}"}}]
},
"finish_reason": "tool_calls"
}]
}"#;
let resp = parse_chat_response(body, "test").unwrap();
assert_eq!(resp.message.tool_calls.len(), 1);
assert_eq!(
resp.message.tool_calls[0].arguments,
serde_json::json!({"x": 1})
);
}
#[test]
fn test_parse_chat_response_malformed_tool_call_args_errors() {
let body = br#"{
"choices": [{
"message": {
"content": null,
"tool_calls": [{"id": "call_1", "function": {"name": "calc", "arguments": "{\"x\": "}}]
},
"finish_reason": "tool_calls"
}]
}"#;
let err = parse_chat_response(body, "test").unwrap_err();
let text = err.to_string();
assert!(text.contains("malformed tool-call arguments"));
assert!(text.contains("calc"));
}
#[test]
fn test_parse_chat_response_empty_tool_call_args_is_not_an_error() {
let body = br#"{
"choices": [{
"message": {
"content": null,
"tool_calls": [{"id": "call_1", "function": {"name": "get_current_time", "arguments": ""}}]
},
"finish_reason": "tool_calls"
}]
}"#;
let resp = parse_chat_response(body, "test").unwrap();
assert_eq!(resp.message.tool_calls.len(), 1);
assert_eq!(
resp.message.tool_calls[0].arguments,
serde_json::Value::Null
);
}
#[test]
fn test_parse_chat_response_whitespace_only_tool_call_args_is_not_an_error() {
let body = br#"{
"choices": [{
"message": {
"content": null,
"tool_calls": [{"id": "call_1", "function": {"name": "get_current_time", "arguments": " "}}]
},
"finish_reason": "tool_calls"
}]
}"#;
let resp = parse_chat_response(body, "test").unwrap();
assert_eq!(resp.message.tool_calls.len(), 1);
assert_eq!(
resp.message.tool_calls[0].arguments,
serde_json::Value::Null
);
}
#[test]
fn test_sse_text_delta() {
let events =
sse_line_events(r#"data: {"choices":[{"delta":{"content":"Hel"},"index":0}]}"#);
assert_eq!(events.len(), 1);
assert!(matches!(&events[0], StreamEvent::TextDelta(t) if t == "Hel"));
}
#[test]
fn test_sse_tool_call() {
let events = sse_line_events(
r#"data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"name":"calc","arguments":"{\"x\""}}]}}]}"#,
);
assert_eq!(events.len(), 2);
assert!(matches!(
&events[0],
StreamEvent::ToolCallStart { id, name } if id == "0" && name == "calc"
));
}
#[test]
fn test_sse_content_filter_emits_error() {
let events =
sse_line_events(r#"data: {"choices":[{"delta":{},"finish_reason":"content_filter"}]}"#);
assert!(events.iter().any(|e| matches!(e, StreamEvent::Error(_))));
}
#[test]
fn test_sse_done() {
let events = sse_line_events("data: [DONE]");
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StreamEvent::Done));
}
#[test]
fn test_sse_ignores_noise() {
assert!(sse_line_events("").is_empty());
assert!(sse_line_events(": keep-alive").is_empty());
assert!(sse_line_events("data: not json").is_empty());
}
#[test]
fn test_api_error_surfaces_body_verbatim() {
let err = api_error(
reqwest::StatusCode::BAD_REQUEST,
r#"{"error":{"message":"failed to parse grammar"}}"#,
"test",
);
let text = err.to_string();
assert!(text.contains("400"));
assert!(text.contains("failed to parse grammar"));
}
#[test]
fn test_debug_redacts_api_key() {
let mut http = Http::new("http://localhost:1234");
http.set_api_key("sk-supersecret-key-value");
let dbg = format!("{http:?}");
assert!(!dbg.contains("sk-supersecret-key-value"));
assert!(dbg.contains("[redacted]"));
}
#[test]
fn test_http_defaults() {
let http = Http::new("http://localhost:1234");
assert_eq!(http.max_retries(), DEFAULT_MAX_RETRIES);
assert_eq!(http.timeout(), None);
}
#[test]
fn test_http_set_max_retries() {
let mut http = Http::new("http://localhost:1234");
http.set_max_retries(7);
assert_eq!(http.max_retries(), 7);
}
#[test]
fn test_http_set_timeout() {
let mut http = Http::new("http://localhost:1234");
http.set_timeout(Duration::from_secs(5));
assert_eq!(http.timeout(), Some(Duration::from_secs(5)));
}
#[test]
fn test_is_retryable_status_429() {
assert!(is_retryable_status(
reqwest::StatusCode::from_u16(429).unwrap()
));
}
#[test]
fn test_is_retryable_status_5xx() {
for code in 500..600 {
assert!(
is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
"status {code} should be retryable"
);
}
}
#[test]
fn test_is_retryable_status_4xx_non_429_not_retryable() {
for code in [400, 401, 404] {
assert!(
!is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
"status {code} should not be retryable"
);
}
}
#[test]
fn test_is_retryable_status_2xx_not_retryable() {
for code in [200, 201] {
assert!(
!is_retryable_status(reqwest::StatusCode::from_u16(code).unwrap()),
"status {code} should not be retryable"
);
}
}
#[tokio::test]
async fn test_is_retryable_transport_error_connect_refused() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let client = reqwest::Client::new();
let err = client
.get(format!("http://{addr}"))
.send()
.await
.unwrap_err();
assert!(err.is_connect());
assert!(is_retryable_transport_error(&err));
}
#[tokio::test]
async fn test_is_retryable_transport_error_timeout() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let _ = listener.accept();
std::thread::sleep(Duration::from_secs(5));
});
let client = reqwest::Client::new();
let err = client
.get(format!("http://{addr}"))
.timeout(Duration::from_millis(100))
.send()
.await
.unwrap_err();
assert!(is_retryable_transport_error(&err), "err was: {err:?}");
}
#[test]
fn test_effective_timeout_non_streaming_default() {
let http = Http::new("http://localhost:1234");
assert_eq!(http.effective_timeout(false), Some(DEFAULT_REQUEST_TIMEOUT));
}
#[test]
fn test_effective_timeout_non_streaming_overridden() {
let mut http = Http::new("http://localhost:1234");
http.set_timeout(Duration::from_secs(5));
assert_eq!(http.effective_timeout(false), Some(Duration::from_secs(5)));
}
#[test]
fn test_effective_timeout_streaming_is_unbounded() {
let http = Http::new("http://localhost:1234");
assert_eq!(http.effective_timeout(true), None);
}
#[tokio::test]
async fn test_post_retries_transport_errors_then_succeeds() {
use std::io::Write;
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let accept_count = Arc::new(AtomicUsize::new(0));
let accept_count_thread = Arc::clone(&accept_count);
std::thread::spawn(move || {
for stream in listener.incoming() {
let n = accept_count_thread.fetch_add(1, Ordering::SeqCst);
let Ok(mut stream) = stream else { continue };
if n < 2 {
drop(stream);
} else {
let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
loop {
use std::io::BufRead;
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 || line == "\r\n" {
break;
}
}
let body =
r#"{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(response.as_bytes());
break;
}
}
});
let mut http = Http::new(&format!("http://{addr}"));
http.set_max_retries(3);
let response = http
.post("/v1/chat/completions", &serde_json::json!({}))
.await
.expect("should succeed after retrying connection-level failures");
assert!(response.status().is_success());
assert!(accept_count.load(Ordering::SeqCst) >= 3);
}
#[tokio::test]
async fn test_post_gives_up_after_max_retries_on_persistent_connection_refused() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let mut http = Http::new(&format!("http://{addr}"));
http.set_max_retries(1);
let err = http
.post("/v1/chat/completions", &serde_json::json!({}))
.await
.unwrap_err();
assert!(err.to_string().contains("HTTP error"));
}
#[test]
fn test_plaintext_api_key_blocked_by_default() {
let mut http = Http::new("http://localhost:1234");
http.set_api_key("secret");
let err = http.check_plaintext_key().unwrap_err();
assert!(matches!(err, DaimonError::Builder(_)));
}
#[test]
fn test_plaintext_api_key_blocked_case_insensitive_scheme() {
let mut http = Http::new("HTTP://localhost:1234");
http.set_api_key("secret");
assert!(http.check_plaintext_key().is_err());
}
#[test]
fn test_plaintext_api_key_allowed_when_opted_in() {
let mut http = Http::new("http://localhost:1234");
http.set_api_key("secret");
http.set_allow_plaintext_api_key(true);
assert!(http.check_plaintext_key().is_ok());
}
#[test]
fn test_plaintext_api_key_not_blocked_over_https() {
let mut http = Http::new("https://localhost:1234");
http.set_api_key("secret");
assert!(http.check_plaintext_key().is_ok());
}
#[test]
fn test_plaintext_key_not_blocked_without_api_key() {
let http = Http::new("http://localhost:1234");
assert!(http.check_plaintext_key().is_ok());
}
#[test]
fn test_parse_embed_response() {
let body = br#"{"data":[{"embedding":[0.1,0.2]}]}"#;
let vecs = parse_embed_response(body, "test").unwrap();
assert_eq!(vecs, vec![vec![0.1, 0.2]]);
}
use proptest::prelude::*;
proptest! {
#[test]
fn parse_chat_response_never_panics(body in prop::collection::vec(any::<u8>(), 0..256)) {
let _ = parse_chat_response(&body, "test");
}
#[test]
fn parse_chat_response_never_panics_on_arbitrary_json(body in "\\PC{0,256}") {
let _ = parse_chat_response(body.as_bytes(), "test");
}
#[test]
fn sse_line_events_into_never_panics(line in "\\PC{0,256}") {
let mut events = Vec::new();
sse_line_events_into(&line, &mut events);
}
}
}