#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
use crate::api::error::ApiError;
#[cfg(any(feature = "anthropic", feature = "gemini"))]
use crate::message::{MessagePart, Role};
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
use futures::StreamExt;
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
use std::time::Duration;
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
mod sse;
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
pub(super) const MAX_RESPONSE_BODY: usize = 10 * 1024 * 1024;
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
pub(super) async fn read_bounded_body(resp: reqwest::Response) -> Result<bytes::Bytes, ApiError> {
if let Some(len) = resp.content_length()
&& usize::try_from(len).map_or(true, |n| n > MAX_RESPONSE_BODY)
{
return Err(ApiError::http(format!(
"response body too large: declared {len} bytes (max {MAX_RESPONSE_BODY})"
)));
}
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|e| ApiError::http(format!("error reading response body: {e}")))?;
buf.extend_from_slice(&chunk);
if buf.len() > MAX_RESPONSE_BODY {
return Err(ApiError::http(format!(
"response body too large: streamed {} bytes (max {MAX_RESPONSE_BODY})",
buf.len()
)));
}
}
Ok(buf.into())
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[derive(Clone)]
pub(super) struct HttpClientConfig {
timeout: Duration,
connect_timeout: Duration,
http: Option<reqwest::Client>,
pool_max_idle_per_host: Option<usize>,
pool_idle_timeout: Option<Duration>,
tcp_keepalive: Option<Duration>,
tcp_nodelay: bool,
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
impl Default for HttpClientConfig {
fn default() -> Self {
Self {
timeout: Duration::from_mins(2),
connect_timeout: Duration::from_secs(10),
http: None,
pool_max_idle_per_host: None,
pool_idle_timeout: None,
tcp_keepalive: None,
tcp_nodelay: true,
}
}
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
impl HttpClientConfig {
#[must_use]
pub(super) fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub(super) fn with_connect_timeout(mut self, timeout: Duration) -> Self {
self.connect_timeout = timeout;
self
}
#[must_use]
pub(super) fn with_http_client(mut self, client: reqwest::Client) -> Self {
self.http = Some(client);
self
}
#[must_use]
pub(super) fn with_pool_max_idle_per_host(mut self, n: usize) -> Self {
self.pool_max_idle_per_host = Some(n);
self
}
#[must_use]
pub(super) fn with_pool_idle_timeout(mut self, d: Duration) -> Self {
self.pool_idle_timeout = Some(d);
self
}
#[must_use]
pub(super) fn with_tcp_keepalive(mut self, d: Duration) -> Self {
self.tcp_keepalive = Some(d);
self
}
#[must_use]
pub(super) fn with_tcp_nodelay(mut self, enabled: bool) -> Self {
self.tcp_nodelay = enabled;
self
}
pub(super) fn build(self) -> Result<reqwest::Client, ApiError> {
match self.http {
Some(shared) => Ok(shared),
None => reqwest::Client::builder()
.timeout(self.timeout)
.connect_timeout(self.connect_timeout)
.tcp_nodelay(self.tcp_nodelay)
.maybe_pool_max_idle_per_host(self.pool_max_idle_per_host)
.maybe_pool_idle_timeout(self.pool_idle_timeout)
.maybe_tcp_keepalive(self.tcp_keepalive)
.build()
.map_err(|e| ApiError::http(e.to_string())),
}
}
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
trait ClientBuilderExt: Sized {
fn maybe_pool_max_idle_per_host(self, val: Option<usize>) -> Self;
fn maybe_pool_idle_timeout(self, val: Option<Duration>) -> Self;
fn maybe_tcp_keepalive(self, val: Option<Duration>) -> Self;
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
impl ClientBuilderExt for reqwest::ClientBuilder {
fn maybe_pool_max_idle_per_host(self, val: Option<usize>) -> Self {
match val {
Some(n) => self.pool_max_idle_per_host(n),
None => self,
}
}
fn maybe_pool_idle_timeout(self, val: Option<Duration>) -> Self {
match val {
Some(d) => self.pool_idle_timeout(Some(d)),
None => self,
}
}
fn maybe_tcp_keepalive(self, val: Option<Duration>) -> Self {
match val {
Some(d) => self.tcp_keepalive(d),
None => self,
}
}
}
#[cfg(feature = "openai")]
pub mod openai;
#[cfg(feature = "anthropic")]
pub mod anthropic;
#[cfg(feature = "gemini")]
pub mod gemini;
#[cfg(feature = "grammar")]
pub mod grammar;
#[cfg(feature = "openai")]
pub use openai::OpenAiClient;
#[cfg(feature = "anthropic")]
pub use anthropic::AnthropicClient;
#[cfg(feature = "gemini")]
pub use gemini::GeminiClient;
#[cfg(feature = "grammar")]
pub use grammar::{JsonSchemaGrammar, ToolGrammarProvider};
#[cfg(feature = "ollama")]
const OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
#[cfg(feature = "deepseek")]
const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1";
#[cfg(feature = "deepseek")]
const DEEPSEEK_DEFAULT_MODEL: &str = "deepseek-chat";
#[cfg(feature = "grok")]
const GROK_BASE_URL: &str = "https://api.x.ai/v1";
#[cfg(feature = "grok")]
const GROK_DEFAULT_MODEL: &str = "grok-beta";
#[cfg(feature = "zai")]
const ZAI_BASE_URL: &str = "https://api.z.ai/api/anthropic";
#[cfg(feature = "zai")]
const ZAI_DEFAULT_MODEL: &str = "glm-4.7";
#[cfg(any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai"
))]
fn env_or_default(name: &str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.into())
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
fn env_or_fallback(primary: &str, fallback: &str) -> Option<String> {
std::env::var(primary)
.or_else(|_| std::env::var(fallback))
.ok()
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
fn require_api_key(primary: &str, fallback: Option<&str>) -> Result<String, ApiError> {
if let Some(fb) = fallback {
if let Some(val) = env_or_fallback(primary, fb) {
return Ok(val);
}
} else if let Ok(val) = std::env::var(primary) {
return Ok(val);
}
Err(ApiError::auth_invalid_key(format!("{primary} not set")))
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
fn fold_system_messages<'a>(
messages: &'a [crate::message::Message],
system: Option<&str>,
) -> (Vec<&'a crate::message::Message>, Option<String>) {
let mut folded = String::new();
let non_system: Vec<&crate::message::Message> = messages
.iter()
.filter(|m| {
if matches!(m.role, Role::System) {
for part in &m.parts {
if let MessagePart::Text { text } = part {
if !folded.is_empty() {
folded.push('\n');
}
folded.push_str(text);
}
}
false
} else {
true
}
})
.collect();
let effective = match (system, folded.is_empty()) {
(Some(s), false) => Some(format!("{s}\n{folded}")),
(Some(s), true) => Some(s.to_string()),
(None, false) => Some(folded),
(None, true) => None,
};
(non_system, effective)
}
#[cfg(feature = "ollama")]
pub fn ollama(model: &str) -> Result<OpenAiClient, ApiError> {
let base = env_or_default("OLLAMA_BASE_URL", OLLAMA_BASE_URL);
let api_key = env_or_default("OLLAMA_API_KEY", "ollama");
OpenAiClient::builder()
.with_api_key(api_key)
.with_base_url(base)
.with_model(model)
.with_stream_usage(false)
.build()
}
#[cfg(feature = "deepseek")]
pub fn deepseek() -> Result<OpenAiClient, ApiError> {
let api_key = require_api_key("DEEPSEEK_API_KEY", None)?;
let model = env_or_default("DEEPSEEK_MODEL", DEEPSEEK_DEFAULT_MODEL);
OpenAiClient::builder()
.with_api_key(api_key)
.with_base_url(DEEPSEEK_BASE_URL)
.with_model(model)
.build()
}
#[cfg(feature = "grok")]
pub fn grok() -> Result<OpenAiClient, ApiError> {
let api_key = require_api_key("XAI_API_KEY", Some("GROK_API_KEY"))?;
let model = std::env::var("XAI_MODEL")
.or_else(|_| std::env::var("GROK_MODEL"))
.unwrap_or_else(|_| GROK_DEFAULT_MODEL.into());
OpenAiClient::builder()
.with_api_key(api_key)
.with_base_url(GROK_BASE_URL)
.with_model(model)
.build()
}
#[cfg(feature = "zai")]
pub fn zai() -> Result<AnthropicClient, ApiError> {
let api_key = require_api_key("ZAI_API_KEY", Some("ZHIPUAI_API_KEY"))?;
let model = env_or_default("ZAI_MODEL", ZAI_DEFAULT_MODEL);
AnthropicClient::builder()
.with_api_key(api_key)
.with_base_url(ZAI_BASE_URL)
.with_model(model)
.build()
}
#[cfg(feature = "openai")]
pub fn self_hosted(base_url: &str, model: &str) -> Result<OpenAiClient, ApiError> {
let api_key = env_or_default("OPENAI_API_KEY", "self-hosted");
OpenAiClient::builder()
.with_api_key(api_key)
.with_base_url(base_url)
.with_model(model)
.build()
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai"
))]
macro_rules! env_set {
($($arg:tt)*) => {{
unsafe { std::env::set_var($($arg)*) }
}};
}
#[cfg(any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai"
))]
macro_rules! env_remove {
($($arg:tt)*) => {{
unsafe { std::env::remove_var($($arg)*) }
}};
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
#[test]
fn env_or_fallback_primary_set() {
env_set!("LOOPCTL_TEST_PRIMARY", "primary-val");
env_remove!("LOOPCTL_TEST_FALLBACK");
assert_eq!(
env_or_fallback("LOOPCTL_TEST_PRIMARY", "LOOPCTL_TEST_FALLBACK"),
Some("primary-val".into())
);
env_remove!("LOOPCTL_TEST_PRIMARY");
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
#[test]
fn env_or_fallback_fallback_used_when_primary_missing() {
env_remove!("LOOPCTL_TEST_PRIMARY2");
env_set!("LOOPCTL_TEST_FALLBACK2", "fallback-val");
assert_eq!(
env_or_fallback("LOOPCTL_TEST_PRIMARY2", "LOOPCTL_TEST_FALLBACK2"),
Some("fallback-val".into())
);
env_remove!("LOOPCTL_TEST_FALLBACK2");
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
#[test]
fn env_or_fallback_none_when_both_missing() {
env_remove!("LOOPCTL_TEST_NEITHER_A");
env_remove!("LOOPCTL_TEST_NEITHER_B");
assert_eq!(
env_or_fallback("LOOPCTL_TEST_NEITHER_A", "LOOPCTL_TEST_NEITHER_B"),
None
);
}
#[cfg(any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai"
))]
#[test]
fn env_or_default_uses_env_when_set() {
env_set!("LOOPCTL_TEST_DEFAULT", "from-env");
assert_eq!(
env_or_default("LOOPCTL_TEST_DEFAULT", "fallback"),
"from-env"
);
env_remove!("LOOPCTL_TEST_DEFAULT");
}
#[cfg(any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai"
))]
#[test]
fn env_or_default_uses_default_when_unset() {
env_remove!("LOOPCTL_TEST_DEFAULT2");
assert_eq!(
env_or_default("LOOPCTL_TEST_DEFAULT2", "fallback"),
"fallback"
);
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
#[test]
fn require_api_key_primary_set() {
env_set!("LOOPCTL_TEST_KEY_PRIMARY", "secret");
env_remove!("LOOPCTL_TEST_KEY_FALLBACK");
let key = require_api_key(
"LOOPCTL_TEST_KEY_PRIMARY",
Some("LOOPCTL_TEST_KEY_FALLBACK"),
)
.unwrap();
assert_eq!(key, "secret");
env_remove!("LOOPCTL_TEST_KEY_PRIMARY");
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
#[test]
fn require_api_key_fallback_used() {
env_remove!("LOOPCTL_TEST_KEY_PRIMARY2");
env_set!("LOOPCTL_TEST_KEY_FALLBACK2", "fallback-secret");
let key = require_api_key(
"LOOPCTL_TEST_KEY_PRIMARY2",
Some("LOOPCTL_TEST_KEY_FALLBACK2"),
)
.unwrap();
assert_eq!(key, "fallback-secret");
env_remove!("LOOPCTL_TEST_KEY_FALLBACK2");
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
#[test]
fn require_api_key_no_fallback_set() {
env_set!("LOOPCTL_TEST_KEY_ONLY", "only-val");
let key = require_api_key("LOOPCTL_TEST_KEY_ONLY", None).unwrap();
assert_eq!(key, "only-val");
env_remove!("LOOPCTL_TEST_KEY_ONLY");
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
#[test]
fn require_api_key_errors_when_missing() {
env_remove!("LOOPCTL_TEST_MISSING_KEY");
let err = require_api_key("LOOPCTL_TEST_MISSING_KEY", None).unwrap_err();
assert!(err.to_string().contains("LOOPCTL_TEST_MISSING_KEY"));
}
#[cfg(any(feature = "deepseek", feature = "grok", feature = "zai"))]
#[test]
fn require_api_key_errors_when_both_missing() {
env_remove!("LOOPCTL_TEST_MISSING_A");
env_remove!("LOOPCTL_TEST_MISSING_B");
let err =
require_api_key("LOOPCTL_TEST_MISSING_A", Some("LOOPCTL_TEST_MISSING_B")).unwrap_err();
assert!(err.to_string().contains("LOOPCTL_TEST_MISSING_A"));
}
#[cfg(feature = "ollama")]
#[test]
fn ollama_client_builds_with_defaults() {
use crate::api::ApiClient;
env_remove!("OLLAMA_BASE_URL");
let client = ollama("llama3").unwrap();
assert_eq!(client.model(), "llama3");
}
#[cfg(feature = "ollama")]
#[test]
fn ollama_client_respects_base_url_env() {
use crate::api::ApiClient;
env_set!("OLLAMA_BASE_URL", "http://my-host:1234/v1");
let client = ollama("test-model").unwrap();
assert_eq!(client.model(), "test-model");
env_remove!("OLLAMA_BASE_URL");
}
#[cfg(feature = "ollama")]
#[test]
fn ollama_client_uses_api_key_when_set() {
use crate::api::ApiClient;
env_remove!("OLLAMA_BASE_URL");
env_set!("OLLAMA_API_KEY", "my-cloud-key");
let client = ollama("llama3").unwrap();
assert_eq!(client.model(), "llama3");
env_remove!("OLLAMA_API_KEY");
}
#[cfg(feature = "ollama")]
#[test]
fn ollama_client_defaults_to_local_without_key() {
use crate::api::ApiClient;
env_remove!("OLLAMA_BASE_URL");
env_remove!("OLLAMA_API_KEY");
let client = ollama("llama3").unwrap();
assert_eq!(client.model(), "llama3");
}
#[cfg(feature = "openai")]
#[test]
fn self_hosted_client_builds() {
use crate::api::ApiClient;
let client = self_hosted("http://localhost:8080/v1", "my-model").unwrap();
assert_eq!(client.model(), "my-model");
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
fn sys_msg(texts: &[&str]) -> crate::message::Message {
use crate::message::{MessagePart, Role};
let parts: Vec<MessagePart> = texts.iter().map(|t| MessagePart::text(*t)).collect();
crate::message::Message::new(Role::System, parts)
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_no_system_messages_no_caller_returns_none() {
let msgs = [crate::message::Message::user("hi")];
let (non_system, system) = fold_system_messages(&msgs, None);
assert_eq!(non_system.len(), 1);
assert!(system.is_none(), "no system content → None");
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_caller_only_passes_through() {
let msgs = [crate::message::Message::user("hi")];
let (non_system, system) = fold_system_messages(&msgs, Some("be brief"));
assert_eq!(non_system.len(), 1);
assert_eq!(system.as_deref(), Some("be brief"));
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_single_system_message_removed_and_folded() {
let msgs = [
crate::message::Message::user("hello"),
sys_msg(&["stay on task"]),
crate::message::Message::assistant("working"),
];
let (non_system, system) = fold_system_messages(&msgs, None);
assert_eq!(non_system.len(), 2, "system message filtered out");
assert_eq!(system.as_deref(), Some("stay on task"));
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_caller_prompt_prepended_to_folded() {
let msgs = [crate::message::Message::user("hi"), sys_msg(&["reminder"])];
let (_non_system, system) = fold_system_messages(&msgs, Some("be brief"));
let system = system.expect("merged system is Some");
assert!(
system.starts_with("be brief"),
"caller prompt first: got {system:?}"
);
assert!(
system.contains("reminder"),
"folded text appended: got {system:?}"
);
assert!(
system.contains('\n'),
"caller and folded are newline-separated: got {system:?}"
);
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_multiple_system_messages_joined_with_newlines() {
let msgs = [
sys_msg(&["first reminder"]),
crate::message::Message::user("hi"),
sys_msg(&["second reminder"]),
];
let (non_system, system) = fold_system_messages(&msgs, None);
assert_eq!(non_system.len(), 1, "both system messages filtered");
let system = system.expect("folded text is Some");
assert_eq!(system, "first reminder\nsecond reminder");
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_only_text_parts_are_folded() {
use crate::message::{MessagePart, Role};
let system_msg = crate::message::Message::new(
Role::System,
vec![
MessagePart::text("keep this"),
MessagePart::tool_call("id", "some_tool", serde_json::json!({})),
],
);
let msgs = [crate::message::Message::user("hi"), system_msg];
let (_non_system, system) = fold_system_messages(&msgs, None);
assert_eq!(system.as_deref(), Some("keep this"));
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_preserves_relative_order_of_non_system_messages() {
let msgs = [
crate::message::Message::user("first"),
sys_msg(&["mid reminder"]),
crate::message::Message::assistant("second"),
crate::message::Message::user("third"),
];
let (non_system, _system) = fold_system_messages(&msgs, None);
let texts: Vec<&str> = non_system
.iter()
.flat_map(|m| {
m.parts.iter().filter_map(|p| match p {
crate::message::MessagePart::Text { text } => Some(text.as_str()),
_ => None,
})
})
.collect();
assert_eq!(texts, vec!["first", "second", "third"]);
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_empty_text_part_contributes_nothing() {
let msgs = [sys_msg(&[""])];
let (non_system, system) = fold_system_messages(&msgs, None);
assert!(non_system.is_empty(), "system message still filtered");
assert!(
system.is_none(),
"empty folded text and no caller → None (got {system:?})"
);
}
#[cfg(any(feature = "anthropic", feature = "gemini"))]
#[test]
fn fold_system_multiple_text_parts_in_one_message_joined() {
let msgs = [sys_msg(&["part one", "part two"])];
let (_non_system, system) = fold_system_messages(&msgs, None);
assert_eq!(system.as_deref(), Some("part one\npart two"));
}
#[test]
fn default_builds_clean() {
assert!(HttpClientConfig::default().build().is_ok());
}
#[test]
fn accepts_injected_http_client() {
let shared = reqwest::Client::new();
let config = HttpClientConfig::default().with_http_client(shared);
assert!(config.build().is_ok());
}
#[test]
fn injected_client_supersedes_timeouts() {
let shared = reqwest::Client::builder()
.timeout(Duration::from_secs(1))
.build()
.unwrap();
let config = HttpClientConfig::default()
.with_http_client(shared)
.with_timeout(Duration::from_secs(99));
assert!(config.build().is_ok());
}
#[test]
fn pool_knobs_build_clean() {
let config = HttpClientConfig::default()
.with_pool_max_idle_per_host(4)
.with_pool_idle_timeout(Duration::from_secs(30))
.with_tcp_keepalive(Duration::from_secs(90));
assert!(config.build().is_ok());
}
#[test]
fn tcp_nodelay_default_is_true() {
assert!(HttpClientConfig::default().tcp_nodelay);
}
#[test]
fn with_tcp_nodelay_can_disable() {
let config = HttpClientConfig::default().with_tcp_nodelay(false);
assert!(!config.tcp_nodelay);
}
#[test]
fn injected_client_ignores_pool_knobs() {
let shared = reqwest::Client::new();
let config = HttpClientConfig::default()
.with_http_client(shared)
.with_pool_max_idle_per_host(4)
.with_pool_idle_timeout(Duration::from_secs(30));
assert!(config.build().is_ok());
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
async fn serve_once(
status: u16,
headers: String,
body: Vec<u8>,
) -> (String, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
drop(sock.read(&mut buf).await);
let extra = if headers.is_empty() {
String::new()
} else {
format!("{headers}\r\n")
};
let head = format!(
"HTTP/1.1 {status} OK\r\nContent-Length: {clen}\r\n{extra}\r\n",
clen = body.len(),
);
drop(sock.write_all(head.as_bytes()).await);
drop(sock.write_all(&body).await);
drop(sock.flush().await);
});
(format!("http://{addr}"), handle)
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
async fn get_response(url: &str) -> reqwest::Response {
reqwest::Client::new()
.get(url)
.send()
.await
.expect("request to test server must succeed")
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[tokio::test]
async fn read_bounded_body_accepts_under_limit() {
let body = b"{\"ok\":true}".to_vec();
let (url, handle) = serve_once(200, String::new(), body.clone()).await;
let resp = get_response(&url).await;
let bytes = read_bounded_body(resp).await.expect("small body must pass");
assert_eq!(bytes.as_ref(), body.as_slice());
handle.await.unwrap();
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[tokio::test]
async fn read_bounded_body_rejects_oversized_content_length() {
let body = vec![b'x'; MAX_RESPONSE_BODY + 1];
let (url, handle) = serve_once(200, String::new(), body).await;
let resp = get_response(&url).await;
let err = read_bounded_body(resp)
.await
.expect_err("oversized body must reject");
assert!(
err.to_string().contains("too large"),
"expected a too-large error, got: {err}"
);
handle.await.unwrap();
}
}