use crate::AppConfig;
use actix_web::{web, HttpRequest, HttpResponse, Result};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
pub mod anthropic;
pub mod completions;
pub mod models;
pub mod openai;
pub mod responses;
pub use anthropic::*;
pub use completions::*;
pub use models::*;
pub use openai::*;
pub use responses::*;
pub(crate) const OPENAI_DEFAULT_MODEL: &str = "gpt-5.6";
pub(crate) fn event_frame(event: &str, data: &serde_json::Value) -> web::Bytes {
web::Bytes::from(format!(
"event: {event}\ndata: {}\n\n",
serde_json::to_string(data).expect("event serializes")
))
}
pub(crate) const STREAM_CHUNK_DELAY_MS: u64 = 50;
pub(crate) fn now_unix() -> i64 {
chrono::Utc::now().timestamp()
}
pub(crate) fn rand_hex24() -> String {
Uuid::new_v4().simple().to_string()[..24].to_string()
}
pub(crate) fn estimate_tokens(text: &str) -> usize {
text.len().div_ceil(4)
}
#[derive(Deserialize)]
pub(crate) struct HttpcanOverride {
pub content: Option<String>,
}
pub(crate) fn override_content<'a>(ov: Option<&'a HttpcanOverride>, default: &'a str) -> &'a str {
ov.and_then(|o| o.content.as_deref())
.filter(|c| !c.is_empty())
.unwrap_or(default)
}
pub(crate) fn content_text(content: &serde_json::Value) -> String {
match content {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Array(items) => items
.iter()
.filter_map(|part| {
let obj = part.as_object()?;
if obj.get("type").and_then(|t| t.as_str()) == Some("text") {
obj.get("text").and_then(|t| t.as_str()).map(str::to_string)
} else {
None
}
})
.collect::<Vec<_>>()
.join(" "),
_ => String::new(),
}
}
pub(crate) fn sse_response() -> actix_web::HttpResponseBuilder {
let mut builder = HttpResponse::Ok();
builder.content_type("text/event-stream");
builder.insert_header(("Cache-Control", "no-cache"));
builder.insert_header(("Connection", "keep-alive"));
builder
}
pub async fn llm_index_handler(
req: HttpRequest,
config: web::Data<AppConfig>,
) -> Result<HttpResponse> {
let base = crate::handlers::utils::resolved_base(&req, &config);
Ok(HttpResponse::Ok().json(json!({
"service": "httpcan LLM mock",
"description": "Mock OpenAI- and Anthropic-compatible LLM APIs. No auth: any API key works.",
"base_url": {
"openai_sdk": format!("{base}/llm/v1"),
"openai_sdk_alias": format!("{base}/llm"),
"anthropic_sdk": format!("{base}/llm"),
},
"endpoints": {
"chat_completions": {
"method": "POST",
"path": "/llm/v1/chat/completions",
"alias": "/llm/chat/completions",
"stream": "stream: true (SSE, chat.completion.chunk frames, [DONE] sentinel)"
},
"messages": {
"method": "POST",
"path": "/llm/v1/messages",
"stream": "stream: true (SSE, Anthropic event protocol)"
},
"responses": {
"method": "POST",
"path": "/llm/v1/responses",
"alias": "/llm/responses",
"stream": "stream: true (named SSE events response.created..response.completed, sequence_number, no [DONE])"
},
"legacy_completions": {
"method": "POST",
"path": "/llm/v1/completions",
"alias": "/llm/completions",
"stream": "stream: true (SSE, text_completion frames, [DONE] sentinel)"
},
"models": {
"method": "GET",
"path": "/llm/v1/models",
"note": "OpenAI shape by default; Anthropic shape when an anthropic-version header is present"
},
"model_detail": {
"method": "GET",
"path": "/llm/v1/models/{model}",
"note": "Always 200: any model id is echoed back as existing"
},
},
"response_override": {
"body_field": "httpcan",
"example": { "httpcan": { "content": "custom reply text" } },
"note": "Pass via extra_body in SDKs that reject unknown fields."
}
})))
}
pub async fn openai_method_not_allowed() -> Result<HttpResponse> {
Ok(HttpResponse::MethodNotAllowed()
.insert_header(("Allow", "POST"))
.json(json!({
"error": {
"message": "Only POST is allowed on this endpoint.",
"type": "invalid_request_error",
"param": null,
"code": null
}
})))
}
pub async fn anthropic_method_not_allowed() -> Result<HttpResponse> {
Ok(HttpResponse::MethodNotAllowed()
.insert_header(("Allow", "POST"))
.json(json!({
"type": "error",
"error": { "type": "invalid_request_error", "message": "Only POST is allowed on this endpoint." }
})))
}