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:
co-authored by
Claude Code
parent
dfd6fa66a7
commit
e351d74fa4
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod common;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(¶ms));
|
||||
|
||||
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(¶ms));
|
||||
|
||||
// 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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod chat;
|
||||
pub mod chat_ui;
|
||||
pub mod health;
|
||||
pub mod models;
|
||||
@@ -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(),
|
||||
}],
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
Reference in New Issue
Block a user