Merge pull request #1 from asepharyana/refactor/clean-architecture

refactor(llm-api): implement clean architecture following scraper pat…
This commit is contained in:
asepharyana
2026-07-25 15:09:04 +07:00
committed by GitHub
29 changed files with 1826 additions and 1008 deletions
Generated
+23 -2
View File
@@ -540,7 +540,7 @@ dependencies = [
"encoding_rs",
"enumflags2",
"llama-cpp-sys-2",
"thiserror",
"thiserror 2.0.19",
"tracing",
"tracing-core",
]
@@ -570,6 +570,7 @@ dependencies = [
"llama-cpp-2",
"serde",
"serde_json",
"thiserror 1.0.69",
"tokio",
"tokio-stream",
"tower-http",
@@ -958,13 +959,33 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.19",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
+3
View File
@@ -17,6 +17,9 @@ tower-http = { version = "0.6", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Error handling
thiserror = "1"
# Utilities
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+3
View File
@@ -0,0 +1,3 @@
pub mod use_cases;
pub use use_cases::{build_prompt, build_sampler, clean_text, parse_tool_calls, SamplerParams};
+294
View File
@@ -0,0 +1,294 @@
//! Chat completion use cases.
//!
//! Orchestrates prompt building, sampler construction, and output parsing.
//! These are pure functions with no framework dependencies.
use llama_cpp_2::sampling::LlamaSampler;
use crate::domain::entity::{ChatMessage, ChatRequest, ToolCall, ToolCallFunction, ToolDef};
/// Build a prompt string from conversation messages and optional tool definitions.
///
/// Uses ChatML format with `<|im_start|>` / `<|im_end|>` delimiters. Tool
/// definitions are injected into the first system message.
pub fn build_prompt(messages: &[ChatMessage], tools: &Option<Vec<ToolDef>>) -> String {
let mut prompt = String::new();
for (i, msg) in messages.iter().enumerate() {
match msg.role.as_str() {
"system" => {
let mut content = msg.content.clone().unwrap_or_default();
// Inject tools into the system message (first occurrence)
if i == 0 {
if let Some(tools_list) = tools {
if !tools_list.is_empty() {
let mut tools_text = String::from(
"\n\n# Tools\n\nYou have access to the following functions:\n\n<tools>",
);
for tool in tools_list {
tools_text.push('\n');
tools_text.push_str(
&serde_json::to_string(tool).unwrap_or_default(),
);
}
tools_text.push_str(
"\n</tools>\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>",
);
content.push_str(&tools_text);
}
}
}
prompt.push_str(&format!("<|im_start|>system\n{}<|im_end|>\n", content));
}
"user" => {
let content = msg.content.as_deref().unwrap_or("");
if msg.tool_call_id.is_some() || msg.name.is_some() {
prompt.push_str(&format!(
"<|im_start|>user\n<tool_response>\n{}\n</tool_response><|im_end|>\n",
content
));
} else {
prompt.push_str(&format!("<|im_start|>user\n{}<|im_end|>\n", content));
}
}
"assistant" => {
let content = msg.content.as_deref().unwrap_or("");
if let Some(tcs) = &msg.tool_calls {
let mut asst = format!("<|im_start|>assistant\n{}", content);
for tc in tcs {
let args: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or_default();
asst.push_str(&format!(
"<tool_call>\n<function={}>\n",
tc.function.name
));
if let Some(obj) = args.as_object() {
for (k, v) in obj {
let val = match v {
serde_json::Value::String(s) => s.clone(),
other => serde_json::to_string(other).unwrap_or_default(),
};
asst.push_str(&format!("<parameter={}>\n{}\n</parameter>\n", k, val));
}
}
asst.push_str("</function>\n</tool_call>");
}
asst.push_str("<|im_end|>\n");
prompt.push_str(&asst);
} else {
prompt.push_str(&format!(
"<|im_start|>assistant\n{}<|im_end|>\n",
content
));
}
}
_ => {
let content = msg.content.as_deref().unwrap_or("");
prompt.push_str(&format!("<|im_start|>user\n{}<|im_end|>\n", content));
}
}
}
// Generation prompt: non-thinking mode
prompt.push_str("<|im_start|>assistant\n<think>\n\n</think>\n\n");
prompt
}
/// Parameters for building a [`LlamaSampler`] chain.
pub struct SamplerParams {
pub temperature: Option<f32>,
pub top_p: Option<f32>,
pub top_k: Option<u32>,
pub min_p: Option<f32>,
pub repeat_penalty: Option<f32>,
pub frequency_penalty: Option<f32>,
pub presence_penalty: Option<f32>,
pub seed: Option<u32>,
}
impl SamplerParams {
pub fn from_request(req: &ChatRequest) -> Self {
Self {
temperature: req.temperature,
top_p: req.top_p,
top_k: req.top_k,
min_p: req.min_p,
repeat_penalty: req.repeat_penalty,
frequency_penalty: req.frequency_penalty,
presence_penalty: req.presence_penalty,
seed: req.seed,
}
}
}
/// Build a [`LlamaSampler`] chain from [`SamplerParams`].
pub fn build_sampler(params: &SamplerParams) -> LlamaSampler {
use llama_cpp_2::sampling::LlamaSampler as LS;
let temperature = params.temperature;
let top_p = params.top_p;
let top_k = params.top_k;
let min_p = params.min_p;
let repeat_penalty = params.repeat_penalty;
let frequency_penalty = params.frequency_penalty;
let presence_penalty = params.presence_penalty;
let seed = params.seed;
let mut samplers: Vec<LlamaSampler> = Vec::new();
// Repetition/frequency/presence penalties
let repeat = repeat_penalty.unwrap_or(1.0);
let freq = frequency_penalty.unwrap_or(0.0);
let present = presence_penalty.unwrap_or(0.0);
if (repeat - 1.0).abs() > 1e-6 || freq > 0.0 || present > 0.0 {
samplers.push(LS::penalties(64, repeat, freq, present));
}
// top_k
if let Some(k) = top_k {
samplers.push(LS::top_k(k as i32));
}
// top_p
if let Some(p) = top_p {
samplers.push(LS::top_p(p, 1));
}
// min_p
if let Some(p) = min_p {
samplers.push(LS::min_p(p, 1));
}
// Temperature + final selector
let temp = temperature.unwrap_or(0.0);
if temp <= 0.0 {
samplers.push(LS::greedy());
} else {
if (temp - 1.0).abs() > 1e-6 {
samplers.push(LS::temp(temp));
}
let s = seed.unwrap_or(0);
samplers.push(LS::dist(s));
}
LlamaSampler::chain_simple(samplers)
}
// ═══════════════════════════════════════════════════════════════
// TEXT PROCESSING
// ═══════════════════════════════════════════════════════════════
/// Remove special tokens from generated text.
pub fn clean_text(text: &str) -> String {
text.replace("<|im_end|>", "")
.replace("<|im_start|>", "")
.replace("<think>", "")
.replace("</think>", "")
.trim()
.to_string()
}
/// Parse tool calls from generated text in the format:
///
/// ```xml
/// <tool_call>
/// <function=name>
/// <parameter=key>value</parameter>
/// </function>
/// </tool_call>
/// ```
pub fn parse_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
let mut clean = text.to_string();
let mut tool_calls: Vec<ToolCall> = Vec::new();
let mut idx = 0;
loop {
let start_tag = "<tool_call>";
let end_tag = "</tool_call>";
let start = match clean[idx..].find(start_tag) {
Some(s) => idx + s,
None => break,
};
let end = match clean[start..].find(end_tag) {
Some(e) => start + e + end_tag.len(),
None => break,
};
let block = &clean[start + start_tag.len()..end - end_tag.len()];
let trimmed = block.trim();
// Parse function name
let func_name = trimmed
.lines()
.next()
.and_then(|l| {
let l = l.trim();
l.strip_prefix("<function=")
.and_then(|s| s.strip_suffix('>'))
.map(|s| s.trim().to_string())
})
.unwrap_or_default();
// Parse parameters
let mut args_map = serde_json::Map::new();
let lines = trimmed.lines();
let mut current_param: Option<String> = None;
let mut current_value = String::new();
let mut in_param = false;
for line in lines {
let line = line.trim();
if let Some(param) =
line.strip_prefix("<parameter=")
.and_then(|s| s.strip_suffix('>'))
{
if let Some(p) = current_param.take() {
args_map.insert(
p,
serde_json::Value::String(current_value.trim().to_string()),
);
current_value = String::new();
}
current_param = Some(param.to_string());
in_param = true;
} else if line == "</parameter>" {
in_param = false;
} else if in_param {
if !current_value.is_empty() {
current_value.push('\n');
}
current_value.push_str(line);
} else if line.starts_with("<function=") || line.starts_with("</function>") {
continue;
}
}
// Save last param
if let Some(p) = current_param.take() {
args_map.insert(p, serde_json::Value::String(current_value.trim().to_string()));
}
let args_json = serde_json::Value::Object(args_map).to_string();
tool_calls.push(ToolCall {
id: format!("call_{}", uuid::Uuid::new_v4().to_string().replace('-', "")),
call_type: "function".into(),
function: ToolCallFunction {
name: func_name,
arguments: args_json,
},
});
idx = end;
}
// Remove tool_call blocks from the text
clean = clean.replace("<tool_call>", "").replace("</tool_call>", "");
// XML-like tags are already fully parsed; remaining text is the content
clean = clean.trim().to_string();
// Strip remaining XML tags that aren't part of clean
let cleaned = clean_text(&clean);
(cleaned, tool_calls)
}
+6
View File
@@ -0,0 +1,6 @@
//! Application Layer — use cases / business orchestration.
//!
//! Contains the chat completion logic: prompt building, sampler construction,
//! and output parsing.
pub mod chat;
+70
View File
@@ -0,0 +1,70 @@
//! Application initialization and lifecycle management.
use std::sync::Arc;
use axum::Router;
use tokio::net::TcpListener;
use tracing_subscriber::EnvFilter;
use crate::config::CONFIG;
use crate::infrastructure::llama::LlamaEngine;
use crate::presentation::router::build_router;
use crate::presentation::state::AppState;
/// The running application.
///
/// Encapsulates the router, listener, and port so that everything can be
/// created in `build()` and then served in `run()`, enabling testability.
pub struct Application {
pub port: u16,
router: Router,
listener: TcpListener,
}
impl Application {
/// Initialize all dependencies and build the application.
///
/// 1. Init tracing subscriber
/// 2. Load config (triggers LazyLock — fails fast on missing vars)
/// 3. Load model and create LlamaEngine
/// 4. Build router with all routes and middleware
/// 5. Bind TCP listener
pub async fn build() -> anyhow::Result<Self> {
// Initialize tracing
let env_filter = EnvFilter::new(&CONFIG.log_level);
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.init();
tracing::info!("🚀 LLM API starting up...");
// Load model (fail-fast)
let engine = LlamaEngine::load().map_err(|e| {
anyhow::anyhow!("Failed to initialize LLM engine: {e}")
})?;
let engine = Arc::new(engine);
let state = Arc::new(AppState { engine });
// Build router
let router = build_router(state);
// Bind listener
let addr = format!("0.0.0.0:{}", CONFIG.server_port);
let listener = TcpListener::bind(&addr).await?;
tracing::info!(
"Server listening on {}",
listener.local_addr()?
);
Ok(Self {
port: CONFIG.server_port,
router,
listener,
})
}
/// Start serving requests.
pub async fn run(self) -> std::io::Result<()> {
axum::serve(self.listener, self.router.into_make_service()).await
}
}
+59
View File
@@ -0,0 +1,59 @@
//! Type-safe application configuration.
//!
//! Loads configuration from environment variables at startup with fail-fast behavior.
use std::sync::LazyLock;
const DEFAULT_MODEL_PATH: &str = "/models/MiniCPM-V-4.6-Q4_K_M.gguf";
pub const MODEL_ID: &str = "minicpm-v-4.6";
/// Application configuration loaded at startup from environment variables.
#[derive(Debug, Clone)]
pub struct AppConfig {
/// Path to the GGUF model file
pub model_path: String,
/// API key for authentication (empty = disabled)
pub api_key: String,
/// Server port to bind to
pub server_port: u16,
/// Log level (trace, debug, info, warn, error)
pub log_level: String,
/// LLM context size (n_ctx)
pub n_ctx: u32,
/// LLM batch size (n_batch)
pub n_batch: u32,
/// Number of CPU threads for inference
pub n_threads: i32,
}
impl AppConfig {
/// Load configuration from environment variables.
pub fn load() -> Self {
Self {
model_path: std::env::var("MODEL_PATH")
.unwrap_or_else(|_| DEFAULT_MODEL_PATH.to_string()),
api_key: std::env::var("API_KEY").unwrap_or_default(),
server_port: std::env::var("SERVER_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8080),
log_level: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
n_ctx: 2048,
n_batch: 512,
n_threads: 4,
}
}
}
/// Global configuration instance, loaded once at startup.
pub static CONFIG: LazyLock<AppConfig> = LazyLock::new(|| {
let config = AppConfig::load();
tracing::info!("Configuration loaded: model={:?}", config.model_path);
config
});
+193
View File
@@ -0,0 +1,193 @@
//! Domain entities for the LLM inference API.
//!
//! Pure data structs with no framework dependencies beyond serde.
//! These represent the OpenAI-compatible API shapes used across all layers.
use serde::{Deserialize, Serialize};
// ═══════════════════════════════════════════════════════════════
// REQUEST TYPES
// ═══════════════════════════════════════════════════════════════
/// OpenAI-compatible chat completion request body.
#[derive(Deserialize)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<ChatMessage>,
pub max_tokens: Option<u32>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub top_p: Option<f32>,
#[serde(default)]
pub top_k: Option<u32>,
#[serde(default)]
pub min_p: Option<f32>,
#[serde(default)]
pub frequency_penalty: Option<f32>,
#[serde(default)]
pub presence_penalty: Option<f32>,
#[serde(default)]
pub repeat_penalty: Option<f32>,
#[serde(default)]
pub seed: Option<u32>,
#[serde(default)]
pub stream: Option<bool>,
#[serde(default)]
pub stop: Option<Vec<String>>,
#[serde(default)]
pub tools: Option<Vec<ToolDef>>,
#[serde(default)]
pub tool_choice: Option<serde_json::Value>,
}
/// A single message in the chat conversation.
#[derive(Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: Option<String>,
#[serde(default)]
pub tool_calls: Option<Vec<ToolCallResponse>>,
#[serde(default)]
pub tool_call_id: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
/// Tool/function definition for function calling.
#[derive(Deserialize, Serialize, Clone)]
pub struct ToolDef {
#[serde(rename = "type")]
pub tool_type: String,
pub function: ToolFunction,
}
#[derive(Deserialize, Serialize, Clone)]
pub struct ToolFunction {
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub parameters: serde_json::Value,
}
// ═══════════════════════════════════════════════════════════════
// RESPONSE TYPES
// ═══════════════════════════════════════════════════════════════
/// OpenAI-compatible chat completion response (non-streaming).
#[derive(Serialize)]
pub struct ChatResponse {
pub id: String,
pub object: String,
pub created: i64,
pub model: String,
pub choices: Vec<Choice>,
pub usage: Usage,
}
#[derive(Serialize)]
pub struct Choice {
pub index: u32,
pub message: ResponseMessage,
pub finish_reason: String,
}
#[derive(Serialize)]
pub struct ResponseMessage {
pub role: String,
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
#[derive(Serialize)]
pub struct Usage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
}
// ═══════════════════════════════════════════════════════════════
// TOOL CALL TYPES
// ═══════════════════════════════════════════════════════════════
/// A tool call in responses or streaming deltas.
#[derive(Serialize, Deserialize, Clone)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: ToolCallFunction,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ToolCallFunction {
pub name: String,
pub arguments: String,
}
/// For parsing tool calls from message history (has extra fields).
#[derive(Deserialize, Clone)]
pub struct ToolCallResponse {
pub id: String,
#[serde(rename = "type")]
pub call_type: String,
pub function: ToolCallFunction,
}
// ═══════════════════════════════════════════════════════════════
// SSE (STREAMING) TYPES
// ═══════════════════════════════════════════════════════════════
/// Server-Sent Event chunk for streaming responses.
#[derive(Serialize)]
pub struct SseChunk {
pub id: String,
pub object: String,
pub created: i64,
pub model: String,
pub choices: Vec<SseChoice>,
}
#[derive(Serialize)]
pub struct SseChoice {
pub index: u32,
pub delta: SseDelta,
#[serde(skip_serializing_if = "Option::is_none")]
pub finish_reason: Option<String>,
}
#[derive(Serialize)]
pub struct SseDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
// ═══════════════════════════════════════════════════════════════
// MODELS & HEALTH TYPES
// ═══════════════════════════════════════════════════════════════
#[derive(Serialize)]
pub struct ModelsResponse {
pub object: String,
pub data: Vec<ModelInfo>,
}
#[derive(Serialize)]
pub struct ModelInfo {
pub id: String,
pub object: String,
pub created: i64,
pub owned_by: String,
}
#[derive(Serialize)]
pub struct HealthResponse {
pub status: String,
pub model: String,
}
+38
View File
@@ -0,0 +1,38 @@
//! Domain-level error types.
//!
//! Framework-agnostic errors that can be mapped to HTTP errors
//! at the presentation layer.
use thiserror::Error;
/// Errors originating from LLM inference operations.
#[derive(Error, Debug)]
pub enum LlmError {
/// Invalid request parameters
#[error("Invalid request: {0}")]
InvalidRequest(String),
/// Model or inference error
#[error("Model error: {0}")]
Model(String),
/// Authentication failure
#[error("Authentication failed")]
Unauthorized,
/// Internal/unexpected error
#[error("Internal error: {0}")]
Internal(String),
}
impl From<String> for LlmError {
fn from(s: String) -> Self {
LlmError::Internal(s)
}
}
impl From<&str> for LlmError {
fn from(s: &str) -> Self {
LlmError::Internal(s.to_string())
}
}
+6
View File
@@ -0,0 +1,6 @@
//! Domain Layer — pure business types, no framework dependencies.
pub mod entity;
pub mod error;
pub use error::LlmError;
+263
View File
@@ -0,0 +1,263 @@
//! LlamaEngine — safe wrapper around llama-cpp-2 inference.
//!
//! Encapsulates model loading, context management (with the unavoidable
//! lifetime transmute), tokenization, and generation.
use std::num::NonZeroU32;
use llama_cpp_2::context::params::LlamaContextParams;
use llama_cpp_2::context::LlamaContext;
use llama_cpp_2::llama_backend::LlamaBackend;
use llama_cpp_2::llama_batch::LlamaBatch;
use llama_cpp_2::model::params::LlamaModelParams;
use llama_cpp_2::model::{AddBos, LlamaModel};
use llama_cpp_2::sampling::LlamaSampler;
use llama_cpp_2::token::LlamaToken;
use llama_cpp_2::TokenToStringError;
use tokio::sync::Mutex;
use tracing::info;
use crate::config::CONFIG;
use crate::domain::LlmError;
// ── Thread-safe wrapper for raw llama.cpp context ──
/// Wrapper around [`LlamaContext`] that makes it Send + Sync.
///
/// # Safety
///
/// The contained context has its lifetime transmuted to `'static` because it is
/// owned by [`LlamaEngine`] which lives for the entire program lifetime (held in
/// an `Arc`). The engine is only dropped at process shutdown, so no dangling
/// reference can be created.
/// Wrapper around LlamaContext with `'static` lifetime for sharing.
pub struct CtxInner {
/// Invariant: this context is dropped only when the engine is destroyed.
context: LlamaContext<'static>,
}
unsafe impl Send for CtxInner {}
unsafe impl Sync for CtxInner {}
/// Wrapper for [`LlamaSampler`] to make it Send + Sync.
///
/// # Safety
///
/// `llama-cpp-2`'s `LlamaSampler` is a C opaque pointer. The underlying
/// `llama.cpp` sampling API is reentrant for distinct contexts and thread-safe
/// when used with a single context from one thread at a time (which we enforce
/// via `Mutex<CtxInner>`).
pub struct SendSampler(pub LlamaSampler);
unsafe impl Send for SendSampler {}
unsafe impl Sync for SendSampler {}
impl std::ops::Deref for SendSampler {
type Target = LlamaSampler;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl std::ops::DerefMut for SendSampler {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl CtxInner {
pub(crate) fn clear(&mut self) {
self.context.clear_kv_cache();
}
pub(crate) fn prefill(&mut self, tokens: &[LlamaToken]) -> Result<(), String> {
let mut batch = LlamaBatch::new(tokens.len(), 1);
for (i, &token) in tokens.iter().enumerate() {
batch
.add(token, i as i32, &[0], i == tokens.len() - 1)
.map_err(|e| e.to_string())?;
}
self.context.decode(&mut batch).map_err(|e| e.to_string())
}
pub(crate) fn sample(&mut self, sampler: &mut LlamaSampler) -> LlamaToken {
sampler.sample(&self.context, -1)
}
pub(crate) fn decode(&mut self, token: LlamaToken, pos: i32) -> Result<(), String> {
let mut batch = LlamaBatch::new(1, 1);
batch
.add(token, pos, &[0], true)
.map_err(|e| e.to_string())?;
self.context.decode(&mut batch).map_err(|e| e.to_string())
}
}
// ── LlamaEngine ──
/// Safe interface to a llama.cpp model and inference context.
///
/// All access to the underlying context is serialized through a `Mutex`,
/// so only one generation can happen at a time. This is intentional —
/// the model is designed for sequential inference.
pub struct LlamaEngine {
/// The loaded model (read-only after load, safe to share).
pub model: LlamaModel,
/// The inference context (single-threaded access via Mutex).
pub ctx: Mutex<CtxInner>,
}
impl LlamaEngine {
/// Load a model from disk and create an inference context.
///
/// # Errors
///
/// Returns `LlmError::Model` if the model cannot be loaded or the context
/// cannot be created.
pub fn load() -> Result<Self, LlmError> {
info!("Initializing llama backend...");
let backend = LlamaBackend::init().map_err(|e| {
LlmError::Model(format!("Backend init failed: {e}"))
})?;
// Backend must outlive model and context. We leak it to achieve 'static
// lifetime since the engine lives for the program lifetime.
let backend: &'static LlamaBackend = Box::leak(Box::new(backend));
info!("Loading model: {}", CONFIG.model_path);
let model = LlamaModel::load_from_file(backend, &CONFIG.model_path, &LlamaModelParams::default())
.map_err(|e| LlmError::Model(format!("Failed to load model: {e}")))?;
info!(" Vocab: {}", model.n_vocab());
info!(" Params: {}", model.n_params());
info!(" Layers: {}", model.n_layer());
info!("Creating context...");
let ctx_params = LlamaContextParams::default()
.with_n_ctx(NonZeroU32::new(CONFIG.n_ctx))
.with_n_batch(CONFIG.n_batch)
.with_n_threads(CONFIG.n_threads)
.with_n_threads_batch(CONFIG.n_threads);
let context = model
.new_context(backend, ctx_params)
.map_err(|e| LlmError::Model(format!("Failed to create context: {e}")))?;
// SAFETY: `context` is tied to `backend`'s lifetime, which we leaked
// above to achieve `'static`. The engine owns both and lives for the
// program duration (held in a global Arc). When the engine is dropped
// at process shutdown, the leaked backend is cleaned up by the OS.
let context: LlamaContext<'static> = unsafe { std::mem::transmute(context) };
Ok(Self {
model,
ctx: Mutex::new(CtxInner { context }),
})
}
/// Tokenize a prompt string into tokens.
pub fn tokenize(&self, prompt: &str) -> Result<Vec<LlamaToken>, LlmError> {
self.model
.str_to_token(prompt, AddBos::Always)
.map_err(|e| LlmError::Model(format!("Tokenization failed: {e}")))
}
/// Decode a single token to its string representation.
pub fn decode_token(&self, token: LlamaToken) -> String {
let bytes = match self.model.token_to_piece_bytes(token, 32, true, None) {
Ok(b) => b,
Err(TokenToStringError::InsufficientBufferSpace(neg)) => {
let size = (-neg).max(0).try_into().unwrap_or(256);
self.model
.token_to_piece_bytes(token, size, true, None)
.unwrap_or_default()
}
_ => return String::new(),
};
String::from_utf8(bytes).unwrap_or_default()
}
/// Decode multiple tokens to a single string.
pub fn decode_tokens(&self, tokens: &[LlamaToken]) -> String {
let mut out = String::with_capacity(tokens.len() * 4);
for &token in tokens {
out.push_str(&self.decode_token(token));
}
out
}
/// Check if a token is an end-of-generation token.
pub fn is_eog(&self, token: LlamaToken) -> bool {
self.model.is_eog_token(token)
}
/// Return a reference to the context mutex for advanced operations.
pub fn ctx(&self) -> &Mutex<CtxInner> {
&self.ctx
}
/// Generate tokens (non-streaming) and return output tokens, cleaned text, and tool calls.
///
/// Locks the context mutex, prefill the prompt, then iterates sampling + decoding
/// until EOG, max_tokens, stop sequence, or tool call completion.
pub async fn generate(
&self,
input_tokens: &[LlamaToken],
sampler: &mut SendSampler,
max_tokens: u32,
stop: &[String],
) -> Result<(Vec<LlamaToken>, String), LlmError> {
let mut inner = self.ctx.lock().await;
inner.clear();
inner
.prefill(input_tokens)
.map_err(|e| LlmError::Model(format!("Prefill: {e}")))?;
let mut output: Vec<LlamaToken> = Vec::new();
let mut text_buf = String::new();
let mut stop_now = false;
let mut current = inner.sample(sampler);
for _ in 0..max_tokens {
if self.model.is_eog_token(current) {
break;
}
let pos = input_tokens.len() as i32 + output.len() as i32;
output.push(current);
let piece = self.decode_token(current);
text_buf.push_str(&piece);
// Check stop sequences
for s in stop {
if text_buf.contains(s) {
stop_now = true;
break;
}
}
if stop_now {
break;
}
// Check for tool_call block completion
if text_buf.contains("<tool_call>") {
let close_count = text_buf.matches("</tool_call>").count();
let open_count = text_buf.matches("<tool_call>").count();
if open_count > 0 && close_count >= open_count {
break;
}
}
if let Err(e) = inner.decode(current, pos) {
tracing::info!(" Decode error: {e}");
break;
}
current = inner.sample(sampler);
}
Ok((output, text_buf))
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod engine;
pub use engine::{LlamaEngine, SendSampler};
+5
View File
@@ -0,0 +1,5 @@
//! Infrastructure Layer — implements external integrations.
//!
//! Contains the LLM engine wrapper around llama-cpp-2.
pub mod llama;
+27
View File
@@ -0,0 +1,27 @@
// Library root — clean architecture module structure
// ============================================================================
// Domain Layer — pure business logic, no framework dependencies
// ============================================================================
pub mod domain;
// ============================================================================
// Application Layer — use cases / business orchestration
// ============================================================================
pub mod application;
// ============================================================================
// Infrastructure Layer — implements domain ports
// ============================================================================
pub mod infrastructure;
// ============================================================================
// Presentation Layer — Axum handlers, middleware, state, error
// ============================================================================
pub mod presentation;
// ============================================================================
// Core Framework & Bootstrap
// ============================================================================
pub mod bootstrap;
pub mod config;
+8 -1006
View File
File diff suppressed because it is too large Load Diff
+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>,
}