#[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"))]
fn sse_data_payload(line: &str) -> Option<&str> {
let payload = line.strip_prefix("data:")?;
Some(payload.strip_prefix(' ').unwrap_or(payload))
}
#[cfg(feature = "anthropic")]
fn sse_event_type(line: &str) -> Option<&str> {
let event = line.strip_prefix("event:")?;
Some(event.strip_prefix(' ').unwrap_or(event))
}
#[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"))]
pub(super) const MAX_ERROR_BODY: usize = 8 * 1024;
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
pub(super) async fn read_error_body(resp: reqwest::Response) -> String {
if let Some(len) = resp.content_length()
&& usize::try_from(len).map_or(true, |n| n > MAX_ERROR_BODY)
{
return String::new();
}
let mut stream = resp.bytes_stream();
let mut buf: Vec<u8> = Vec::new();
while buf.len() < MAX_ERROR_BODY
&& let Some(chunk) = stream.next().await
{
match chunk {
Ok(bytes) => {
let remaining = MAX_ERROR_BODY.saturating_sub(buf.len());
buf.extend_from_slice(bytes.get(..remaining).unwrap_or(&bytes));
}
Err(_) => break,
}
}
String::from_utf8_lossy(&buf).into_owned()
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
fn classify_error_response(status: u16, body: String, retry_after: Option<Duration>) -> ApiError {
match status {
401 => ApiError::auth_invalid_key(format!("HTTP {status}: {body}")),
403 => ApiError::auth(format!("HTTP {status}: {body}")),
429 | 503 | 529 => ApiError::rate_limited(format!("HTTP {status}: {body}"), retry_after),
_ => ApiError::http_with_status(status, body),
}
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
pub(super) async fn post_json_checked(
client: &reqwest::Client,
url: &str,
headers: &[(reqwest::header::HeaderName, reqwest::header::HeaderValue)],
body: &serde_json::Value,
) -> Result<reqwest::Response, ApiError> {
let request = headers.iter().fold(client.post(url), |req, (name, value)| {
req.header(name.clone(), value.clone())
});
let resp = request
.json(body)
.send()
.await
.map_err(|e| ApiError::http(e.to_string()))?;
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
let retry_after = resp
.headers()
.get(reqwest::header::RETRY_AFTER)
.and_then(|value| value.to_str().ok())
.and_then(crate::api::error::parse_retry_after);
let body_text = read_error_body(resp).await;
Err(classify_error_response(
status.as_u16(),
body_text,
retry_after,
))
}
#[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()
.read_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 = "bedrock")]
pub mod bedrock;
#[cfg(feature = "bedrock")]
pub use bedrock::BedrockClient;
#[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(feature = "moonshot")]
const MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
#[cfg(feature = "moonshot")]
const MOONSHOT_DEFAULT_MODEL: &str = "kimi-k2-0905-preview";
#[cfg(any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai",
feature = "azure",
feature = "moonshot"
))]
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",
feature = "azure",
feature = "moonshot"
))]
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",
feature = "azure",
feature = "moonshot"
))]
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 = "azure")]
pub fn azure(resource: impl AsRef<str>) -> Result<OpenAiClient, ApiError> {
let resource = resource.as_ref();
let chars_ok = resource
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-');
let edges_ok = resource
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric())
&& resource
.chars()
.next_back()
.is_some_and(|c| c.is_ascii_alphanumeric());
if !(2..=64).contains(&resource.chars().count()) || !chars_ok || !edges_ok {
return Err(ApiError::config_validation(format!(
"azure: resource name {resource:?} must be 2–64 characters of \
alphanumerics and hyphens, starting and ending with an alphanumeric"
)));
}
let api_key = require_api_key("AZURE_OPENAI_API_KEY", None)?;
let deployment = std::env::var("AZURE_OPENAI_MODEL").map_err(|_| {
ApiError::config(
"azure: AZURE_OPENAI_MODEL is missing — set it to the deployment \
name configured in your Azure OpenAI resource",
)
})?;
let base = format!("https://{resource}.openai.azure.com/openai/v1");
OpenAiClient::builder()
.with_api_key(api_key)
.with_base_url(base)
.with_model(deployment)
.build()
}
#[cfg(feature = "moonshot")]
pub fn moonshot() -> Result<OpenAiClient, ApiError> {
let api_key = require_api_key("MOONSHOT_API_KEY", None)?;
let model = env_or_default("MOONSHOT_MODEL", MOONSHOT_DEFAULT_MODEL);
OpenAiClient::builder()
.with_api_key(api_key)
.with_base_url(MOONSHOT_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(feature = "testing")]
use crate::testing::EnvGuard;
#[cfg(all(feature = "azure", feature = "testing"))]
#[test]
fn azure_rejects_invalid_resource_names() {
let too_long = "r".repeat(65);
let bads = [
"",
"a",
"under_score",
"sp ace",
"res.name",
"-lead",
"trail-",
too_long.as_str(),
];
for bad in bads {
let Err(err) = azure(bad) else {
panic!("validation runs before any env access: {bad:?} accepted");
};
assert!(err.to_string().contains("resource name"), "{bad:?}: {err}");
assert_eq!(
err.code(),
crate::api::error::ErrorCode::ConfigValidationError,
"{bad:?}: {err}"
);
}
let env = EnvGuard::acquire(&["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_MODEL"]);
env.remove("AZURE_OPENAI_API_KEY");
env.remove("AZURE_OPENAI_MODEL");
for good in ["ab", "my-resource", &"r".repeat(64)] {
let Err(err) = azure(good) else {
panic!("valid name rejected: {good:?}")
};
assert!(
!err.to_string().contains("resource name"),
"{good:?} is valid; the failure must be env-related: {err}"
);
}
}
#[cfg(all(feature = "azure", feature = "testing"))]
#[test]
fn azure_builds_the_v1_client_from_env() {
use crate::api::ApiClient as _;
let env = EnvGuard::acquire(&["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_MODEL"]);
env.set("AZURE_OPENAI_API_KEY", "key");
env.set("AZURE_OPENAI_MODEL", "my-deployment");
let client = azure("my-resource").unwrap();
assert_eq!(
client.base_url(),
"https://my-resource.openai.azure.com/openai/v1"
);
assert_eq!(client.model(), "my-deployment");
}
#[cfg(all(feature = "azure", feature = "testing"))]
#[test]
fn azure_requires_key_and_model() {
let env = EnvGuard::acquire(&["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_MODEL"]);
env.set("AZURE_OPENAI_API_KEY", "key");
env.remove("AZURE_OPENAI_MODEL");
let Err(err) = azure("res") else {
panic!("missing AZURE_OPENAI_MODEL must fail the build");
};
assert!(err.to_string().contains("AZURE_OPENAI_MODEL"), "{err}");
assert_eq!(
err.code(),
crate::api::error::ErrorCode::ConfigMissing,
"{err}"
);
env.remove("AZURE_OPENAI_API_KEY");
let Err(err) = azure("res") else {
panic!("missing AZURE_OPENAI_API_KEY must fail the build");
};
assert!(err.to_string().contains("AZURE_OPENAI_API_KEY"), "{err}");
}
#[cfg(all(feature = "moonshot", feature = "testing"))]
#[test]
fn moonshot_builds_client_and_defaults_model() {
use crate::api::ApiClient as _;
let env = EnvGuard::acquire(&["MOONSHOT_API_KEY", "MOONSHOT_MODEL"]);
env.set("MOONSHOT_API_KEY", "key");
env.remove("MOONSHOT_MODEL");
let client = moonshot().unwrap();
assert_eq!(client.base_url(), "https://api.moonshot.ai/v1");
assert_eq!(client.model(), MOONSHOT_DEFAULT_MODEL);
env.set("MOONSHOT_MODEL", "custom");
assert_eq!(moonshot().unwrap().model(), "custom");
}
#[cfg(all(feature = "moonshot", feature = "testing"))]
#[test]
fn moonshot_requires_key() {
let env = EnvGuard::acquire(&["MOONSHOT_API_KEY"]);
env.remove("MOONSHOT_API_KEY");
assert!(moonshot().is_err());
}
#[cfg(any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai"
))]
#[cfg(all(
any(feature = "deepseek", feature = "grok", feature = "zai"),
feature = "testing"
))]
#[test]
fn env_or_fallback_primary_set() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_PRIMARY", "LOOPCTL_TEST_FALLBACK"]);
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())
);
}
#[cfg(all(
any(feature = "deepseek", feature = "grok", feature = "zai"),
feature = "testing"
))]
#[test]
fn env_or_fallback_fallback_used_when_primary_missing() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_PRIMARY2", "LOOPCTL_TEST_FALLBACK2"]);
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())
);
}
#[cfg(all(
any(feature = "deepseek", feature = "grok", feature = "zai"),
feature = "testing"
))]
#[test]
fn env_or_fallback_none_when_both_missing() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_NEITHER_A", "LOOPCTL_TEST_NEITHER_B"]);
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(all(
any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai"
),
feature = "testing"
))]
#[test]
fn env_or_default_uses_env_when_set() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_DEFAULT"]);
env.set("LOOPCTL_TEST_DEFAULT", "from-env");
assert_eq!(
env_or_default("LOOPCTL_TEST_DEFAULT", "fallback"),
"from-env"
);
}
#[cfg(all(
any(
feature = "ollama",
feature = "deepseek",
feature = "grok",
feature = "zai",
feature = "openai"
),
feature = "testing"
))]
#[test]
fn env_or_default_uses_default_when_unset() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_DEFAULT2"]);
env.remove("LOOPCTL_TEST_DEFAULT2");
assert_eq!(
env_or_default("LOOPCTL_TEST_DEFAULT2", "fallback"),
"fallback"
);
}
#[cfg(all(
any(feature = "deepseek", feature = "grok", feature = "zai"),
feature = "testing"
))]
#[test]
fn require_api_key_primary_set() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_KEY_PRIMARY", "LOOPCTL_TEST_KEY_FALLBACK"]);
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");
}
#[cfg(all(
any(feature = "deepseek", feature = "grok", feature = "zai"),
feature = "testing"
))]
#[test]
fn require_api_key_fallback_used() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_KEY_PRIMARY2", "LOOPCTL_TEST_KEY_FALLBACK2"]);
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");
}
#[cfg(all(
any(feature = "deepseek", feature = "grok", feature = "zai"),
feature = "testing"
))]
#[test]
fn require_api_key_no_fallback_set() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_KEY_ONLY"]);
env.set("LOOPCTL_TEST_KEY_ONLY", "only-val");
let key = require_api_key("LOOPCTL_TEST_KEY_ONLY", None).unwrap();
assert_eq!(key, "only-val");
}
#[cfg(all(
any(feature = "deepseek", feature = "grok", feature = "zai"),
feature = "testing"
))]
#[test]
fn require_api_key_errors_when_missing() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_MISSING_KEY"]);
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(all(
any(feature = "deepseek", feature = "grok", feature = "zai"),
feature = "testing"
))]
#[test]
fn require_api_key_errors_when_both_missing() {
let env = EnvGuard::acquire(&["LOOPCTL_TEST_MISSING_A", "LOOPCTL_TEST_MISSING_B"]);
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(all(feature = "ollama", feature = "testing"))]
#[test]
fn ollama_client_builds_with_defaults() {
use crate::api::ApiClient;
let env = EnvGuard::acquire(&["OLLAMA_BASE_URL"]);
env.remove("OLLAMA_BASE_URL");
let client = ollama("llama3").unwrap();
assert_eq!(client.model(), "llama3");
}
#[cfg(all(feature = "ollama", feature = "testing"))]
#[test]
fn ollama_client_respects_base_url_env() {
use crate::api::ApiClient;
let env = EnvGuard::acquire(&["OLLAMA_BASE_URL"]);
env.set("OLLAMA_BASE_URL", "http://my-host:1234/v1");
let client = ollama("test-model").unwrap();
assert_eq!(client.model(), "test-model");
}
#[cfg(all(feature = "ollama", feature = "testing"))]
#[test]
fn ollama_client_uses_api_key_when_set() {
use crate::api::ApiClient;
let env = EnvGuard::acquire(&["OLLAMA_BASE_URL", "OLLAMA_API_KEY"]);
env.remove("OLLAMA_BASE_URL");
env.set("OLLAMA_API_KEY", "my-cloud-key");
let client = ollama("llama3").unwrap();
assert_eq!(client.model(), "llama3");
}
#[cfg(all(feature = "ollama", feature = "testing"))]
#[test]
fn ollama_client_defaults_to_local_without_key() {
use crate::api::ApiClient;
let env = EnvGuard::acquire(&["OLLAMA_BASE_URL", "OLLAMA_API_KEY"]);
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"));
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[test]
fn default_builds_clean() {
assert!(HttpClientConfig::default().build().is_ok());
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[test]
fn accepts_injected_http_client() {
let shared = reqwest::Client::new();
let config = HttpClientConfig::default().with_http_client(shared);
assert!(config.build().is_ok());
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[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());
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[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());
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[test]
fn tcp_nodelay_default_is_true() {
assert!(HttpClientConfig::default().tcp_nodelay);
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[test]
fn with_tcp_nodelay_can_disable() {
let config = HttpClientConfig::default().with_tcp_nodelay(false);
assert!(!config.tcp_nodelay);
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[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_error_body_caps_chunked_oversized_response() {
use std::sync::atomic::{AtomicUsize, Ordering};
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 written = std::sync::Arc::new(AtomicUsize::new(0));
let counter = written.clone();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
drop(sock.read(&mut buf).await);
let head = "HTTP/1.1 500 Internal Server Error\r\nConnection: close\r\n\r\n";
drop(sock.write_all(head.as_bytes()).await);
let chunk = vec![b'x'; 16384];
for _ in 0..16 {
tokio::task::yield_now().await;
if sock.write_all(&chunk).await.is_err() {
break;
}
counter.fetch_add(chunk.len(), Ordering::SeqCst);
drop(sock.flush().await);
}
});
let resp = get_response(&format!("http://{addr}")).await;
let text = read_error_body(resp).await;
server.await.unwrap();
assert_eq!(
text.len(),
MAX_ERROR_BODY,
"a chunked oversized error body must retain exactly the capped prefix"
);
assert!(
text.chars().all(|c| c == 'x'),
"the retained prefix must be the body's leading bytes"
);
let total = written.load(Ordering::SeqCst);
assert!(
total < 16 * 16384,
"the read must abort the transfer short of the full 256 KiB body; the server wrote {total} bytes"
);
}
#[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();
}
#[cfg(feature = "openai")]
#[tokio::test]
async fn error_body_read_is_bounded_by_the_cap() {
use crate::api::ApiClient as _;
use std::sync::atomic::{AtomicUsize, Ordering};
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 written = std::sync::Arc::new(AtomicUsize::new(0));
let counter = written.clone();
let server = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
drop(sock.read(&mut buf).await);
let head = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 2097152\r\nConnection: close\r\n\r\n";
drop(sock.write_all(head.as_bytes()).await);
let chunk = vec![b'x'; 8192];
for _ in 0..256 {
tokio::task::yield_now().await;
if sock.write_all(&chunk).await.is_err() {
break;
}
counter.fetch_add(chunk.len(), Ordering::SeqCst);
drop(sock.flush().await);
}
});
let client = crate::provider::OpenAiClient::builder()
.with_api_key("k")
.with_base_url(format!("http://{addr}"))
.build()
.expect("client builds");
let result = client
.create_message(&crate::api::StreamRequest::new(vec![]))
.await;
assert!(result.is_err(), "a 500 response must surface an error");
server.await.unwrap();
let total = written.load(Ordering::SeqCst);
assert!(
total < 2 * 1024 * 1024,
"the error-body cap (8 KiB) must stop the read short of the declared 2 MiB body; the server wrote {total} bytes"
);
}
#[cfg(any(feature = "openai", feature = "anthropic", feature = "gemini"))]
#[test]
fn sse_data_payload_accepts_spaced_and_compact_forms() {
assert_eq!(sse_data_payload("data: {\"a\":1}"), Some("{\"a\":1}"));
assert_eq!(sse_data_payload("data:{\"a\":1}"), Some("{\"a\":1}"));
assert_eq!(
sse_data_payload("data: two spaces"),
Some(" two spaces"),
"only the first space after the colon is framing; a second space is payload"
);
assert_eq!(
sse_data_payload("data:"),
Some(""),
"a bare data field carries an empty payload, not a skipped line"
);
assert_eq!(sse_data_payload("event: message_start"), None);
assert_eq!(sse_data_payload(": keep-alive comment"), None);
assert_eq!(
sse_data_payload("DATA: {\"a\":1}"),
None,
"SSE field names are case-sensitive; only lowercase data fields carry payloads"
);
}
#[cfg(feature = "anthropic")]
#[test]
fn sse_event_type_accepts_spaced_and_compact_forms() {
assert_eq!(
sse_event_type("event: message_start"),
Some("message_start")
);
assert_eq!(sse_event_type("event:message_start"), Some("message_start"));
assert_eq!(
sse_event_type("event: two spaces"),
Some(" two spaces"),
"only the first space after the colon is framing; a second space is part of the name"
);
assert_eq!(
sse_event_type("event:"),
Some(""),
"a bare event field carries an empty name, not a skipped line"
);
assert_eq!(sse_event_type("data: {}"), None);
assert_eq!(
sse_event_type("EVENT: message_start"),
None,
"SSE field names are case-sensitive; only lowercase event fields name events"
);
}
#[cfg(all(feature = "zai", feature = "testing"))]
#[test]
fn zai_default_model_matches_the_documented_default() {
use crate::api::ApiClient;
let env = EnvGuard::acquire(&["ZAI_API_KEY", "ZAI_MODEL"]);
env.set("ZAI_API_KEY", "test-key");
env.remove("ZAI_MODEL");
let client = zai().expect("client builds with the test key");
assert_eq!(
client.model(),
"glm-4.7",
"the deliberate default is glm-4.7; the doc was corrected to match"
);
}
}