use std::time::Duration;
use daimon_core::{
ChatRequest, ChatResponse, DaimonError, EmbeddingModel, Model, ResponseStream, Result,
};
use crate::openai_compat::{
EmbedRequest, Http, api_error, build_chat_request, parse_chat_response, parse_embed_response,
stream_chat_response,
};
#[derive(Debug)]
pub struct OpenAiCompatible {
http: Http,
model: Option<String>,
extra: serde_json::Map<String, serde_json::Value>,
}
impl OpenAiCompatible {
pub fn new(base_url: impl Into<String>) -> Self {
let base_url = base_url.into();
Self {
http: Http::new(&base_url),
model: None,
extra: serde_json::Map::new(),
}
}
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.http.set_base_url(url);
self
}
pub fn with_model(mut self, name: impl Into<String>) -> Self {
self.model = Some(name.into());
self
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.http.set_api_key(key);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.http.set_timeout(timeout);
self
}
pub fn with_max_retries(mut self, retries: u32) -> Self {
self.http.set_max_retries(retries);
self
}
pub fn allow_plaintext_api_key(mut self) -> Self {
self.http.set_allow_plaintext_api_key(true);
self
}
pub fn with_extra_field(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.extra.insert(key.into(), value);
self
}
}
impl Model for OpenAiCompatible {
#[tracing::instrument(skip_all, fields(model = self.model.as_deref().unwrap_or("openai-compatible")))]
async fn generate(&self, request: &ChatRequest) -> Result<ChatResponse> {
let body = build_chat_request(
&request.messages,
&request.tools,
self.model.as_deref(),
request.temperature,
request.max_tokens,
false,
self.extra.clone(),
);
let response = self.http.post("/v1/chat/completions", &body).await?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(api_error(status, &text, "OpenAI-compatible"));
}
let bytes = response.bytes().await.map_err(|e| {
DaimonError::Model(format!("OpenAI-compatible response read error: {e}"))
})?;
parse_chat_response(&bytes, "OpenAI-compatible")
}
#[tracing::instrument(skip_all, fields(model = self.model.as_deref().unwrap_or("openai-compatible")))]
async fn generate_stream(&self, request: &ChatRequest) -> Result<ResponseStream> {
let body = build_chat_request(
&request.messages,
&request.tools,
self.model.as_deref(),
request.temperature,
request.max_tokens,
true,
self.extra.clone(),
);
let response = self
.http
.post_streaming("/v1/chat/completions", &body)
.await?;
let status = response.status();
if !status.is_success() {
let text = response.text().await.unwrap_or_default();
return Err(api_error(status, &text, "OpenAI-compatible"));
}
Ok(stream_chat_response(response, "OpenAI-compatible"))
}
}
#[derive(Debug)]
pub struct OpenAiCompatibleEmbedding {
http: Http,
model: Option<String>,
dimensions: usize,
}
impl OpenAiCompatibleEmbedding {
pub fn new(base_url: impl Into<String>) -> Self {
let base_url = base_url.into();
Self {
http: Http::new(&base_url),
model: None,
dimensions: 768,
}
}
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.http.set_base_url(url);
self
}
pub fn with_model(mut self, name: impl Into<String>) -> Self {
self.model = Some(name.into());
self
}
pub fn with_api_key(mut self, key: impl Into<String>) -> Self {
self.http.set_api_key(key);
self
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.http.set_timeout(timeout);
self
}
pub fn with_max_retries(mut self, retries: u32) -> Self {
self.http.set_max_retries(retries);
self
}
pub fn allow_plaintext_api_key(mut self) -> Self {
self.http.set_allow_plaintext_api_key(true);
self
}
pub fn with_dimensions(mut self, dims: usize) -> Self {
self.dimensions = dims;
self
}
}
impl EmbeddingModel for OpenAiCompatibleEmbedding {
async fn embed(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>> {
let body = EmbedRequest {
model: self.model.as_deref(),
input: texts,
};
let resp = self.http.post("/v1/embeddings", &body).await?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(api_error(status, &text, "OpenAI-compatible"));
}
let bytes = resp.bytes().await.map_err(|e| {
DaimonError::Model(format!("OpenAI-compatible embedding read error: {e}"))
})?;
parse_embed_response(&bytes, "OpenAI-compatible")
}
fn dimensions(&self) -> usize {
self.dimensions
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_defaults() {
let model = OpenAiCompatible::new("http://localhost:8000");
assert_eq!(model.http.base_url(), "http://localhost:8000");
assert!(model.model.is_none());
assert!(model.extra.is_empty());
}
#[test]
fn test_extra_field() {
let model = OpenAiCompatible::new("http://localhost:8000")
.with_extra_field("repetition_penalty", serde_json::json!(1.1));
assert_eq!(model.extra["repetition_penalty"], 1.1);
}
#[test]
fn test_builder_chain() {
let model = OpenAiCompatible::new("http://localhost:8000")
.with_model("my-model")
.with_api_key("secret")
.with_timeout(Duration::from_secs(15))
.with_max_retries(5);
assert_eq!(model.model.as_deref(), Some("my-model"));
assert_eq!(model.http.api_key(), Some("secret"));
assert_eq!(model.http.max_retries(), 5);
}
#[test]
fn test_embedding_builder_defaults() {
let embed = OpenAiCompatibleEmbedding::new("http://localhost:8000");
assert_eq!(embed.dimensions, 768);
}
#[test]
fn test_debug_redacts_api_key() {
let model =
OpenAiCompatible::new("http://localhost:8000").with_api_key("sk-supersecret-key-value");
let dbg = format!("{model:?}");
assert!(
!dbg.contains("sk-supersecret-key-value"),
"Debug output must not contain the plaintext API key: {dbg}"
);
assert!(dbg.contains("[redacted]"));
}
#[test]
fn test_embedding_debug_redacts_api_key() {
let embed = OpenAiCompatibleEmbedding::new("http://localhost:8000")
.with_api_key("sk-supersecret-embed-key");
let dbg = format!("{embed:?}");
assert!(
!dbg.contains("sk-supersecret-embed-key"),
"Debug output must not contain the plaintext API key: {dbg}"
);
assert!(dbg.contains("[redacted]"));
}
#[tokio::test]
async fn test_plaintext_api_key_over_http_is_blocked_by_default() {
let model = OpenAiCompatible::new("http://localhost:8000").with_api_key("secret");
let request = ChatRequest::new(vec![daimon_core::Message::user("hi")]);
let err = model.generate(&request).await.unwrap_err();
assert!(matches!(err, DaimonError::Builder(_)));
}
#[tokio::test]
async fn test_plaintext_api_key_allowed_when_opted_in_does_not_hard_error() {
let model = OpenAiCompatible::new("http://localhost:1")
.with_api_key("secret")
.with_max_retries(0)
.allow_plaintext_api_key();
let request = ChatRequest::new(vec![daimon_core::Message::user("hi")]);
let err = model.generate(&request).await.unwrap_err();
assert!(!matches!(err, DaimonError::Builder(_)));
}
}