use apiplant_abi::FunctionAccess;
use apiplant_ai::{ChatRequest, Event};
use futures_util::StreamExt;
use ntex::util::Bytes;
use ntex::web::types::{Json, State};
use ntex::web::{HttpRequest, HttpResponse};
use serde::Deserialize;
use serde_json::json;
use crate::response::{error, ok};
use crate::sse;
use crate::state::AppState;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct Body {
#[serde(flatten)]
chat: ChatRequest,
stream: Option<bool>,
}
pub async fn config(state: State<AppState>) -> HttpResponse {
let Some(ai) = &state.ai else {
return error(404, "this app has no ai assistant");
};
ok(&json!({
"provider": ai.provider().as_str(),
"model": ai.model(),
"access": state.app.config.ai.access,
"streaming": true,
"agents": state.app.agents.values().map(|agent| {
json!({
"name": agent.meta.name,
"description": agent.meta.description,
"access": agent.permissions.chat.as_string(),
"scope": match agent.meta.scope {
apiplant_core::Scope::Global => "global",
apiplant_core::Scope::Organization => "organization",
},
"storage": agent.meta.storage.enabled,
"reasoning_enabled": agent.merged_ai_config(&state.app.config.ai).reasoning,
"tools": agent.tools.iter().map(|tool| {
json!({
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
"output_schema": tool.output_schema,
})
}).collect::<Vec<_>>(),
})
}).collect::<Vec<_>>(),
}))
}
pub async fn chat(req: HttpRequest, state: State<AppState>, body: Json<Body>) -> HttpResponse {
let Some(ai) = state.ai.clone() else {
return error(404, "this app has no ai assistant");
};
let access = FunctionAccess::parse(&state.app.config.ai.access)
.unwrap_or(FunctionAccess::Private);
if let Err(response) = crate::access::check(&state, &req, &access, "no ai assistant").await {
return response;
}
let body = body.into_inner();
let request = body.chat;
if body.stream == Some(false) {
return match ai.chat(&request).await {
Ok(reply) => match serde_json::to_value(&reply) {
Ok(value) => ok(&value),
Err(e) => {
tracing::error!(error = %e, "unserialisable chat reply");
error(500, "internal error")
}
},
Err(e) => refused(e),
};
}
let stream = match ai.stream(&request).await {
Ok(stream) => stream,
Err(e) => return refused(e),
};
let ended = std::rc::Rc::new(std::cell::Cell::new(false));
let closing = ended.clone();
let events = stream
.map(move |event| -> Result<Bytes, sse::Never> {
Ok(match event {
Ok(Event::Delta(text)) => sse::delta(&text),
Ok(Event::Reasoning(text)) => sse::event("reasoning", &json!({ "text": text })),
Ok(Event::Done(done)) => {
ended.set(true);
sse::done(&serde_json::to_value(&done).unwrap_or_else(|_| json!({})))
}
Err(e) => {
tracing::warn!(error = %e, "ai stream failed mid-answer");
sse::failure(&e.to_string())
}
})
})
.chain(futures_util::stream::once(async move {
Ok(match closing.get() {
true => Bytes::new(),
false => sse::done(&json!({})),
})
}));
let mut response = HttpResponse::Ok();
sse::headers(&mut response);
response.streaming(Box::pin(events))
}
fn refused(e: apiplant_ai::AiError) -> HttpResponse {
match e {
apiplant_ai::AiError::Request(message) => error(400, message),
apiplant_ai::AiError::Provider {
provider,
status,
body,
} => {
tracing::warn!(provider, status, body = %body, "the ai provider refused a request");
HttpResponse::BadGateway().json(&json!({
"error": format!("the ai provider refused this request: {body}"),
"provider": provider,
"provider_status": status,
}))
}
other => {
tracing::error!(error = %other, "ai request failed");
HttpResponse::BadGateway().json(&json!({ "error": other.to_string() }))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_posted_body_is_a_conversation_plus_how_to_answer_it() {
let body: Body = serde_json::from_str(
r#"{"messages":[{"role":"user","content":"hi"}],"model":"m","stream":false}"#,
)
.unwrap();
assert_eq!(body.chat.messages.len(), 1);
assert_eq!(body.chat.model.as_deref(), Some("m"));
assert_eq!(body.stream, Some(false));
let default: Body = serde_json::from_str(r#"{"messages":[]}"#).unwrap();
assert_eq!(default.stream, None);
}
#[test]
fn a_provider_refusal_is_a_502_naming_the_provider() {
let response = refused(apiplant_ai::AiError::Provider {
provider: "openai".to_string(),
status: 429,
body: "rate limited".to_string(),
});
assert_eq!(response.status().as_u16(), 502);
let bad = refused(apiplant_ai::AiError::Request("no messages".to_string()));
assert_eq!(bad.status().as_u16(), 400);
}
#[test]
fn the_done_event_carries_the_ending_as_json() {
let done = apiplant_ai::Done {
finish_reason: "stop".to_string(),
input_tokens: Some(9),
output_tokens: Some(4),
};
let value = serde_json::to_value(&done).unwrap();
assert!(value.is_object());
assert_eq!(value["finish_reason"], "stop");
assert_eq!(value["output_tokens"], 4);
}
}