refactor(llm-api): implement clean architecture following scraper pattern

Split monolithic 1012-line main.rs into layered hexagonal architecture:
- Domain: entity types and LlmError enum
- Application: prompt building, sampler construction, tool call parsing
- Infrastructure: LlamaEngine wrapping llama-cpp-2 with isolated unsafe transmute
- Presentation: Axum handlers, middleware (auth), error chain, router
- Config: type-safe AppConfig with LazyLock
- Bootstrap: Application struct with build() + run()

Resolves build_sampler/build_sampler_params duplication.
Adds simple web chat UI at GET /.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-07-25 15:07:19 +07:00
co-authored by Claude Code
parent dfd6fa66a7
commit e351d74fa4
29 changed files with 1826 additions and 1008 deletions
+30
View File
@@ -0,0 +1,30 @@
//! Common API response types.
use serde::Serialize;
#[derive(Serialize)]
pub struct ApiResponse<T> {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
}
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
message: None,
data: Some(data),
}
}
pub fn error(message: String) -> Self {
Self {
success: false,
message: Some(message),
data: None,
}
}
}
+1
View File
@@ -0,0 +1 @@
pub mod common;
+98
View File
@@ -0,0 +1,98 @@
//! Application-level HTTP error handling.
//!
//! Maps domain errors into HTTP responses.
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize;
use thiserror::Error;
use crate::domain::LlmError;
/// Top-level HTTP error returned by all API handlers.
#[derive(Error, Debug)]
pub enum AppError {
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Not found: {0}")]
NotFound(String),
#[error("Authentication failed")]
Unauthorized,
#[error("Model error: {0}")]
LlmError(String),
#[error("Internal error: {0}")]
Internal(String),
}
// ── From impls — convert domain errors to AppError ──
impl From<LlmError> for AppError {
fn from(err: LlmError) -> Self {
match err {
LlmError::InvalidRequest(msg) => AppError::BadRequest(msg),
LlmError::Model(msg) => AppError::LlmError(msg),
LlmError::Unauthorized => AppError::Unauthorized,
LlmError::Internal(msg) => AppError::Internal(msg),
}
}
}
impl From<String> for AppError {
fn from(s: String) -> Self {
AppError::Internal(s)
}
}
impl From<&str> for AppError {
fn from(s: &str) -> Self {
AppError::Internal(s.to_string())
}
}
// ── Error Response DTO ──
#[derive(Serialize)]
struct ErrorBody {
error: String,
message: String,
}
// ── IntoResponse — render AppError as HTTP response ──
impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response {
let (status, error_msg, detail_msg) = match &self {
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg.as_str()),
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg.as_str()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized", "Invalid API key"),
AppError::LlmError(_msg) => {
tracing::error!(%self, "LLM error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"Internal server error",
)
}
AppError::Internal(_) => {
tracing::error!(%self, "Internal error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
"Internal server error",
)
}
};
let body = Json(ErrorBody {
error: error_msg.to_string(),
message: detail_msg.to_string(),
});
(status, body).into_response()
}
}
+246
View File
@@ -0,0 +1,246 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Chat</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f0f13;
--surface: #1a1a23;
--surface2: #24243a;
--border: #2e2e48;
--text: #e4e4ef;
--text2: #9494b8;
--accent: #7c6aff;
--accent-hover: #9484ff;
--user-msg: #2a2a48;
--assistant-msg: #1a1a28;
--font: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
html, body { height: 100%; background: var(--bg); color: var(--text); font-family: var(--font); }
body { display: flex; flex-direction: column; }
header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex; align-items: center; gap: 12px;
flex-shrink: 0;
}
header h1 { font-size: 18px; font-weight: 600; }
header .badge {
font-size: 11px; padding: 2px 10px;
border-radius: 99px; background: var(--accent);
color: #fff; font-weight: 500;
}
#chat-container {
flex: 1; overflow-y: auto; padding: 24px;
display: flex; flex-direction: column; gap: 16px;
}
#chat-container:empty::after {
content: 'Send a message to start chatting.';
color: var(--text2); font-size: 14px;
text-align: center; margin-top: 40px;
}
.msg {
max-width: 720px; width: fit-content;
padding: 12px 16px; border-radius: 12px;
line-height: 1.6; font-size: 14px;
white-space: pre-wrap; word-break: break-word;
}
.msg.user {
background: var(--user-msg);
border: 1px solid var(--border);
align-self: flex-end;
border-bottom-right-radius: 4px;
}
.msg.assistant {
background: var(--assistant-msg);
border: 1px solid var(--border);
align-self: flex-start;
border-bottom-left-radius: 4px;
}
.msg.assistant:empty::after { content: '⏳'; } /* spinner when empty */
.msg .tool-call {
margin-top: 8px; padding: 8px 12px;
background: var(--surface2); border-radius: 8px;
font-size: 13px; color: var(--text2);
display: flex; align-items: center; gap: 6px;
}
.msg .tool-call::before { content: '\1F527'; }
.msg.error {
background: #2a1818; border-color: #4a2828; color: #f08080;
}
#input-area {
padding: 16px 24px;
border-top: 1px solid var(--border);
background: var(--surface);
display: flex; gap: 12px; align-items: flex-end;
flex-shrink: 0;
}
#input {
flex: 1; resize: none; padding: 12px 16px;
border: 1px solid var(--border); border-radius: 12px;
background: var(--bg); color: var(--text);
font-family: var(--font); font-size: 14px; line-height: 1.4;
outline: none; transition: border-color .15s;
min-height: 48px; max-height: 160px;
}
#input:focus { border-color: var(--accent); }
#input::placeholder { color: var(--text2); }
#send-btn {
padding: 12px 24px; border: none; border-radius: 12px;
background: var(--accent); color: #fff;
font-size: 14px; font-weight: 500; cursor: pointer;
transition: background .15s; white-space: nowrap;
}
#send-btn:hover { background: var(--accent-hover); }
#send-btn:disabled { opacity: .4; cursor: not-allowed; }
.status {
text-align: center; font-size: 12px; color: var(--text2);
padding: 4px 0; display: none;
}
.status.visible { display: block; }
</style>
</head>
<body>
<header>
<h1>AI Chat</h1>
<span class="badge">llm-api</span>
</header>
<div id="chat-container"></div>
<div class="status" id="status"></div>
<div id="input-area">
<textarea id="input" rows="1" placeholder="Type your message..."
@keydown="if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send() }"></textarea>
<button id="send-btn" onclick="send()">Send</button>
</div>
<script>
const CHAT_URL = '/v1/chat/completions';
const $in = document.getElementById('input');
const $btn = document.getElementById('send-btn');
const $container = document.getElementById('chat-container');
const $status = document.getElementById('status');
let messages = [];
function addMsg(role, text, extra) {
const el = document.createElement('div');
el.className = 'msg ' + role;
if (extra?.error) el.classList.add('error');
if (text) el.textContent = text;
if (extra?.toolCalls?.length) {
for (const tc of extra.toolCalls) {
const t = document.createElement('div');
t.className = 'tool-call';
t.textContent = tc.function?.name || 'tool call';
el.appendChild(t);
}
}
$container.appendChild(el);
el.scrollIntoView({ behavior: 'smooth' });
return el;
}
async function send() {
const text = $in.value.trim();
if (!text || $btn.disabled) return;
$in.value = '';
$in.style.height = 'auto';
$btn.disabled = true;
$status.className = 'status visible';
$status.textContent = 'AI is thinking...';
messages.push({ role: 'user', content: text });
addMsg('user', text);
const el = addMsg('assistant', '');
try {
const res = await fetch(CHAT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: '',
messages: messages.slice(-5), // keep context window manageable
stream: true,
max_tokens: 1024,
}),
});
if (!res.ok) {
const errText = await res.text().catch(() => 'Unknown error');
el.textContent = `Error ${res.status}: ${errText}`;
el.classList.add('error');
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let full = '';
let toolCalls = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (!data || data === '[DONE]') continue;
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta;
const finish = chunk.choices?.[0]?.finish_reason;
if (delta?.content) {
full += delta.content;
el.textContent = full;
}
if (delta?.tool_calls) {
toolCalls = delta.tool_calls;
}
if (finish === 'tool_calls' && toolCalls) {
const t = document.createElement('div');
t.className = 'tool-call';
t.textContent = 'Calling tool: ' + toolCalls.map(tc => tc.function?.name).join(', ');
el.appendChild(t);
}
} catch (e) { /* skip malformed chunk */ }
}
}
if (full) messages.push({ role: 'assistant', content: full });
$status.className = 'status';
} catch (e) {
el.textContent = 'Network error: ' + e.message;
el.classList.add('error');
} finally {
$btn.disabled = false;
$status.className = 'status';
$in.focus();
}
}
// Auto-resize textarea
$in.addEventListener('input', () => {
$in.style.height = 'auto';
$in.style.height = Math.min($in.scrollHeight, 160) + 'px';
});
$in.focus();
</script>
</body>
</html>
+310
View File
@@ -0,0 +1,310 @@
//! Chat completions endpoint — streaming and non-streaming.
use std::convert::Infallible;
use std::sync::Arc;
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::{Json, response::{IntoResponse, Response}};
use chrono::Utc;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use tracing::info;
use crate::application::chat;
use crate::domain::entity::{
ChatRequest, ChatResponse, Choice, ResponseMessage, SseChunk, SseChoice, SseDelta, Usage,
};
use crate::infrastructure::llama::SendSampler;
use crate::presentation::error::AppError;
use crate::presentation::state::AppState;
/// POST /v1/chat/completions
pub async fn chat_completions(
State(state): State<Arc<AppState>>,
Json(req): Json<ChatRequest>,
) -> Result<Response, AppError> {
let max_tokens = req.max_tokens.unwrap_or(256).min(1024);
let stop = req.stop.clone().unwrap_or_default();
let prompt = chat::build_prompt(&req.messages, &req.tools);
// Tokenize
let input_tokens = state
.engine
.tokenize(&prompt)
.map_err(|e| AppError::LlmError(e.to_string()))?;
let prompt_tokens = input_tokens.len() as u32;
info!(
" Chat: {} prompt tokens, max_tokens={}, tools={}",
prompt_tokens,
max_tokens,
req.tools.as_ref().is_some_and(|t| !t.is_empty())
);
let response = if req.stream.unwrap_or(false) {
handle_streaming(state.clone(), req, max_tokens, stop, input_tokens).await?
} else {
handle_non_streaming(state.clone(), req, max_tokens, stop, input_tokens).await?
};
Ok(response.into_response())
}
// ── Non-streaming path ──
async fn handle_non_streaming(
state: Arc<AppState>,
req: ChatRequest,
max_tokens: u32,
stop: Vec<String>,
input_tokens: Vec<llama_cpp_2::token::LlamaToken>,
) -> Result<Response, AppError> {
let chat_id = format!("chatcmpl-{}", uuid::Uuid::new_v4());
let created = Utc::now().timestamp();
let prompt_tokens = input_tokens.len() as u32;
let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
let params = chat::SamplerParams::from_request(&req);
let mut sampler = SendSampler(chat::build_sampler(&params));
let (output_tokens, raw_text) = state
.engine
.generate(&input_tokens, &mut sampler, max_tokens, &stop)
.await?;
let (output_text, tool_calls) = chat::parse_tool_calls(&chat::clean_text(&raw_text));
let completion_tokens = output_tokens.len() as u32;
info!(" {} generated tokens", completion_tokens);
let finish_reason = if has_tools && !tool_calls.is_empty() {
"tool_calls"
} else if completion_tokens < max_tokens {
"stop"
} else {
"length"
};
Ok(Json(ChatResponse {
id: chat_id,
object: "chat.completion".into(),
created,
model: req.model,
choices: vec![Choice {
index: 0,
message: ResponseMessage {
role: "assistant".into(),
content: Some(output_text),
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
},
finish_reason: finish_reason.into(),
}],
usage: Usage {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
.into_response())
}
// ── Streaming path ──
async fn handle_streaming(
state: Arc<AppState>,
req: ChatRequest,
max_tokens: u32,
stop: Vec<String>,
input_tokens: Vec<llama_cpp_2::token::LlamaToken>,
) -> Result<Response, AppError> {
let chat_id = format!("chatcmpl-{}", uuid::Uuid::new_v4());
let created = Utc::now().timestamp();
let has_tools = req.tools.as_ref().is_some_and(|t| !t.is_empty());
let model_name = req.model.clone();
let params = chat::SamplerParams::from_request(&req);
let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(64);
tokio::spawn(async move {
// Role chunk
let role_chunk = serde_json::to_string(&SseChunk {
id: chat_id.clone(),
object: "chat.completion.chunk".into(),
created,
model: model_name.clone(),
choices: vec![SseChoice {
index: 0,
delta: SseDelta {
role: Some("assistant".into()),
content: None,
tool_calls: None,
},
finish_reason: None,
}],
})
.unwrap();
if tx.send(Ok(Event::default().data(role_chunk))).await.is_err() {
return;
}
// Build sampler
let mut sampler = SendSampler(chat::build_sampler(&params));
// Lock context
let mut inner = state.engine.ctx().lock().await;
inner.clear();
if let Err(e) = inner.prefill(&input_tokens) {
info!(" Prefill error: {e}");
return;
}
let mut count = 0u32;
let mut text_buf = String::new();
let mut current = inner.sample(&mut sampler);
loop {
if count >= max_tokens {
let chunk = serde_json::to_string(&SseChunk {
id: chat_id.clone(),
object: "chat.completion.chunk".into(),
created,
model: model_name.clone(),
choices: vec![SseChoice {
index: 0,
delta: SseDelta {
role: None,
content: None,
tool_calls: None,
},
finish_reason: Some("length".into()),
}],
})
.unwrap();
let _ = tx.send(Ok(Event::default().data(chunk))).await;
break;
}
if state.engine.is_eog(current) {
let reason = if has_tools && text_buf.contains("<tool_call>") {
"tool_calls"
} else {
"stop"
};
let chunk = serde_json::to_string(&SseChunk {
id: chat_id.clone(),
object: "chat.completion.chunk".into(),
created,
model: model_name.clone(),
choices: vec![SseChoice {
index: 0,
delta: SseDelta {
role: None,
content: None,
tool_calls: None,
},
finish_reason: Some(reason.into()),
}],
})
.unwrap();
let _ = tx.send(Ok(Event::default().data(chunk))).await;
break;
}
let piece = state.engine.decode_token(current);
let content = chat::clean_text(&piece);
if !content.is_empty() {
let chunk = serde_json::to_string(&SseChunk {
id: chat_id.clone(),
object: "chat.completion.chunk".into(),
created,
model: model_name.clone(),
choices: vec![SseChoice {
index: 0,
delta: SseDelta {
role: None,
content: Some(content.clone()),
tool_calls: None,
},
finish_reason: None,
}],
})
.unwrap();
if tx.send(Ok(Event::default().data(chunk))).await.is_err() {
break;
}
}
text_buf.push_str(&piece);
// Check stop sequences
let mut stop_now = false;
for s in &stop {
if text_buf.contains(s) {
stop_now = true;
break;
}
}
if stop_now {
let chunk = serde_json::to_string(&SseChunk {
id: chat_id.clone(),
object: "chat.completion.chunk".into(),
created,
model: model_name.clone(),
choices: vec![SseChoice {
index: 0,
delta: SseDelta {
role: None,
content: None,
tool_calls: None,
},
finish_reason: Some("stop".into()),
}],
})
.unwrap();
let _ = tx.send(Ok(Event::default().data(chunk))).await;
break;
}
// Check tool call completeness
if has_tools && text_buf.contains("<tool_call>") {
let open = text_buf.matches("<tool_call>").count();
let close = text_buf.matches("</tool_call>").count();
if close >= open {
let chunk = serde_json::to_string(&SseChunk {
id: chat_id.clone(),
object: "chat.completion.chunk".into(),
created,
model: model_name.clone(),
choices: vec![SseChoice {
index: 0,
delta: SseDelta {
role: None,
content: None,
tool_calls: None,
},
finish_reason: Some("tool_calls".into()),
}],
})
.unwrap();
let _ = tx.send(Ok(Event::default().data(chunk))).await;
break;
}
}
let pos = input_tokens.len() as i32 + count as i32;
if let Err(e) = inner.decode(current, pos) {
info!(" Decode error: {e}");
break;
}
count += 1;
current = inner.sample(&mut sampler);
}
});
let stream = ReceiverStream::new(rx);
let sse = Sse::new(stream).keep_alive(KeepAlive::default());
Ok(sse.into_response())
}
+16
View File
@@ -0,0 +1,16 @@
//! Chat UI — simple web interface for interacting with the LLM.
use axum::http::{header, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
const HTML: &str = include_str!("chat-ui/index.html");
/// GET / — serve the chat UI page.
pub async fn chat_ui() -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"))],
HTML,
)
.into_response()
}
+13
View File
@@ -0,0 +1,13 @@
//! Health check endpoint.
use axum::Json;
use crate::config::MODEL_ID;
use crate::domain::entity::HealthResponse;
pub async fn health_check() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".into(),
model: format!("{MODEL_ID}-q4_k_m"),
})
}
+4
View File
@@ -0,0 +1,4 @@
pub mod chat;
pub mod chat_ui;
pub mod health;
pub mod models;
+19
View File
@@ -0,0 +1,19 @@
//! Models list endpoint.
use axum::Json;
use chrono::Utc;
use crate::config::MODEL_ID;
use crate::domain::entity::{ModelInfo, ModelsResponse};
pub async fn list_models() -> Json<ModelsResponse> {
Json(ModelsResponse {
object: "list".into(),
data: vec![ModelInfo {
id: MODEL_ID.into(),
object: "model".into(),
created: Utc::now().timestamp(),
owned_by: "asepharyana".into(),
}],
})
}
+39
View File
@@ -0,0 +1,39 @@
//! Authentication middleware.
//!
//! Checks for a valid Bearer token in the Authorization header.
//! Only applied to routes that require authentication.
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::Response;
use crate::config::CONFIG;
/// Middleware that validates the Bearer token in the Authorization header.
///
/// If `API_KEY` is not set (empty), authentication is disabled and
/// all requests pass through. If set, the middleware rejects requests
/// without a matching token.
pub async fn auth_middleware(request: Request, next: Next) -> Result<Response, (StatusCode, String)> {
let api_key = &CONFIG.api_key;
if api_key.is_empty() {
return Ok(next.run(request).await);
}
let header = request
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let expected = format!("Bearer {api_key}");
if header == expected || header == api_key {
return Ok(next.run(request).await);
}
Err((
StatusCode::UNAUTHORIZED,
"{\"error\":\"unauthorized\",\"message\":\"Invalid API key\"}".into(),
))
}
+1
View File
@@ -0,0 +1 @@
pub mod auth;
+8
View File
@@ -0,0 +1,8 @@
//! Presentation Layer — Axum handlers, middleware, state, and error handling.
pub mod dto;
pub mod error;
pub mod handler;
pub mod middleware;
pub mod router;
pub mod state;
+27
View File
@@ -0,0 +1,27 @@
//! Axum router assembly.
use std::sync::Arc;
use axum::middleware;
use axum::routing::{get, post};
use axum::Router;
use tower_http::cors::CorsLayer;
use super::handler::{chat, chat_ui, health, models};
use crate::presentation::middleware::auth::auth_middleware;
use crate::presentation::state::AppState;
/// Build the main application router with all routes and middleware.
pub fn build_router(state: Arc<AppState>) -> Router {
Router::new()
// Public routes (no auth)
.route("/", get(chat_ui::chat_ui))
.route("/health", get(health::health_check))
.route("/v1/models", get(models::list_models))
// Chat completions (auth-protected)
.route("/v1/chat/completions", post(chat::chat_completions))
.route_layer(middleware::from_fn(auth_middleware))
// Global middleware
.layer(CorsLayer::permissive())
.with_state(state)
}
+13
View File
@@ -0,0 +1,13 @@
//! Application state shared across all handlers.
use std::sync::Arc;
use crate::infrastructure::llama::LlamaEngine;
/// Shared application state injected into every handler via Axum State.
///
/// Contains the infrastructure dependencies that handlers need.
#[derive(Clone)]
pub struct AppState {
pub engine: Arc<LlamaEngine>,
}