use super::*;
use actix_web::{web, HttpResponse, Result};
use serde::Deserialize;
use serde_json::json;
use std::time::Duration;
use tokio::time::sleep;
const DEFAULT_MODEL: &str = OPENAI_DEFAULT_MODEL;
const DEFAULT_CONTENT: &str = "This is a mock chat completion from httpcan. Point any OpenAI-compatible SDK at this base_url, and every request gets this deterministic placeholder response.";
#[derive(Deserialize)]
struct ChatCompletionsRequest {
model: Option<String>,
messages: Option<Vec<InMessage>>,
n: Option<u32>,
stream: Option<bool>,
stream_options: Option<StreamOptions>,
httpcan: Option<HttpcanOverride>,
}
#[derive(Deserialize)]
struct InMessage {
role: Option<String>,
content: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct StreamOptions {
include_usage: Option<bool>,
}
pub(crate) fn openai_bad_request(message: String) -> HttpResponse {
HttpResponse::BadRequest().json(json!({
"error": {
"message": message,
"type": "invalid_request_error",
"param": null,
"code": null
}
}))
}
pub(crate) fn usage_json(prompt_tokens: usize, completion_tokens: usize) -> serde_json::Value {
json!({
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
"prompt_tokens_details": null,
"completion_tokens_details": null
})
}
fn stream_chunk(
id: &str,
created: i64,
model: &str,
choices: serde_json::Value,
) -> serde_json::Value {
json!({
"id": id,
"object": "chat.completion.chunk",
"created": created,
"model": model,
"choices": choices
})
}
fn choice(delta: serde_json::Value, finish_reason: Option<&str>) -> serde_json::Value {
json!({
"index": 0,
"delta": delta,
"logprobs": null,
"finish_reason": finish_reason
})
}
pub async fn chat_completions_handler(body: web::Bytes) -> Result<HttpResponse> {
let req: ChatCompletionsRequest = match serde_json::from_slice(&body) {
Ok(req) => req,
Err(err) => {
return Ok(openai_bad_request(format!(
"Invalid JSON in request body: {err}"
)))
}
};
let Some(messages) = req.messages else {
return Ok(openai_bad_request(
"Missing required parameter: 'messages'.".into(),
));
};
let model = req
.model
.filter(|m| !m.is_empty())
.unwrap_or_else(|| DEFAULT_MODEL.to_string());
let n = req.n.unwrap_or(1).clamp(1, 128);
let prompt_text = messages
.iter()
.map(|m| {
format!(
"{}: {}\n",
m.role.as_deref().unwrap_or(""),
content_text(m.content.as_ref().unwrap_or(&serde_json::Value::Null))
)
})
.fold(String::new(), |acc, line| acc + &line);
let prompt_tokens = estimate_tokens(&prompt_text);
let content = override_content(req.httpcan.as_ref(), DEFAULT_CONTENT).to_string();
let completion_tokens = estimate_tokens(&content);
let id = format!("chatcmpl-{}", rand_hex24());
let created = now_unix();
if req.stream.unwrap_or(false) {
return Ok(stream_chat_completion(
&id,
created,
&model,
&content,
prompt_tokens,
completion_tokens,
req.stream_options.as_ref().and_then(|o| o.include_usage) == Some(true),
));
}
let choices: Vec<serde_json::Value> = (0..n)
.map(|i| {
json!({
"index": i,
"message": {
"role": "assistant",
"content": content,
"refusal": null
},
"logprobs": null,
"finish_reason": "stop"
})
})
.collect();
Ok(HttpResponse::Ok().json(json!({
"id": id,
"object": "chat.completion",
"created": created,
"model": model,
"choices": choices,
"usage": usage_json(prompt_tokens, completion_tokens)
})))
}
pub(crate) fn sse_frame(chunk: &serde_json::Value) -> web::Bytes {
web::Bytes::from(format!(
"data: {}\n\n",
serde_json::to_string(chunk).expect("chunk serializes")
))
}
#[allow(clippy::too_many_arguments)]
fn stream_chat_completion(
id: &str,
created: i64,
model: &str,
content: &str,
prompt_tokens: usize,
completion_tokens: usize,
include_usage: bool,
) -> HttpResponse {
let id = id.to_string();
let model = model.to_string();
let words: Vec<String> = content
.split_whitespace()
.enumerate()
.map(|(i, w)| {
if i == 0 {
w.to_string()
} else {
format!(" {w}")
}
})
.collect();
let stream = async_stream::stream! {
yield Ok::<web::Bytes, actix_web::Error>(sse_frame(&stream_chunk(&id, created, &model, json!([choice(
json!({ "role": "assistant", "content": "" }),
None
)]))));
for word in &words {
yield Ok(sse_frame(&stream_chunk(&id, created, &model, json!([choice(
json!({ "content": word }),
None
)]))));
sleep(Duration::from_millis(STREAM_CHUNK_DELAY_MS)).await;
}
yield Ok(sse_frame(&stream_chunk(&id, created, &model, json!([choice(
json!({}),
Some("stop")
)]))));
if include_usage {
let mut chunk = stream_chunk(&id, created, &model, json!([]));
chunk["usage"] = usage_json(prompt_tokens, completion_tokens);
yield Ok(sse_frame(&chunk));
}
yield Ok(web::Bytes::from("data: [DONE]\n\n"));
};
sse_response().streaming(stream)
}