Compare commits

..
Author SHA1 Message Date
Asep Haryana 9a63ff1601 feat: use GGUF chat template via apply_chat_template
- Replaced manual prompt building with LlamaModel::apply_chat_template
- Uses model's baked-in Jinja template (system/user/assistant/tool format)
- Added <think> trigger after template for thinking mode
- Clean_text strips only <|im_end|>, <|im_start|>, <think>, </think>
2026-07-26 17:10:42 +07:00
Asep Haryana 59c77108a5 fix: skip leading EOS tokens in streaming + non-streaming
- Skip <|im_end|> generated as first token (prevents empty responses)
- Clean <think> tags as plain text in generated output
2026-07-26 16:09:04 +07:00
Asep Haryana 495b9ed126 fix: remove /think trigger, add back <think> in clean_text
- Removed /think trigger from prompt (model generates better without it)
- Added <think> and </think> back to clean_text (model uses plain text
  thinking tags, not special tokens)
2026-07-26 16:03:18 +07:00
Asep Haryana 254532458b Revert "fix: remove /think trigger from prompt, use plain assistant prefix"
This reverts commit 7c8f747faf.
2026-07-26 15:49:04 +07:00
Asep Haryana 7c8f747faf fix: remove /think trigger from prompt, use plain assistant prefix 2026-07-26 15:48:44 +07:00
Asep Haryana d8425ea3b3 feat: switch to MiniCPM5-1B-Claude-Opus-Fable5-V2-Thinking-Q8_0 GGUF
- Updated model path + model ID for new 1B thinking model
- Updated prompt builder to use MiniCPM5 native /think trigger
- Updated clean_text to strip MiniCPM5 special tokens
- Bumped n_ctx from 2048 to 8192
2026-07-26 15:38:05 +07:00
asepharyana 14032db705 ci: add workflow_dispatch to notify-parent for manual triggering 2026-07-25 18:13:35 +07:00
asepharyanaandClaude Code 2a5ab8b6d5 ci(llm-api): add notify-parent workflow
Trigger repository_dispatch to parent hub repo on push to master.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-07-25 15:11:38 +07:00
asepharyana 7e717cc808 Merge pull request #1 from asepharyana/refactor/clean-architecture
refactor(llm-api): implement clean architecture following scraper pat…
2026-07-25 15:09:04 +07:00
6 changed files with 124 additions and 90 deletions

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
name: Notify Parent Repo
on:
push:
branches:
- master
workflow_dispatch:
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Trigger root monorepo build
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.DISPATCH_TOKEN }}
repository: asepharyana/asepharyana-hub
event-type: submodule-updated
client-payload: |
{
"service": "llm-api",
"ref": "${{ github.ref }}",
"sha": "${{ github.sha }}",
"actor": "${{ github.actor }}"
}
+50 -85
View File
@@ -1,97 +1,72 @@
//! Chat completion use cases. //! Chat completion use cases.
//! //!
//! Orchestrates prompt building, sampler construction, and output parsing. //! Orchestrates prompt building, sampler construction, and output parsing.
//! These are pure functions with no framework dependencies.
use llama_cpp_2::model::LlamaChatMessage;
use llama_cpp_2::sampling::LlamaSampler; use llama_cpp_2::sampling::LlamaSampler;
use crate::domain::entity::{ChatMessage, ChatRequest, ToolCall, ToolCallFunction, ToolDef}; use crate::domain::entity::{ChatRequest, ToolCall, ToolCallFunction};
/// Build a prompt string from conversation messages and optional tool definitions. /// Build a prompt string from conversation messages using the model's baked-in
/// /// chat template. The template handles system/user/assistant/tool messages,
/// Uses ChatML format with `<|im_start|>` / `<|im_end|>` delimiters. Tool /// thinking mode, and tool definitions automatically.
/// definitions are injected into the first system message. pub fn build_prompt(
pub fn build_prompt(messages: &[ChatMessage], tools: &Option<Vec<ToolDef>>) -> String { model: &llama_cpp_2::model::LlamaModel,
let mut prompt = String::new(); messages: &[crate::domain::entity::ChatMessage],
_tools: &Option<Vec<crate::domain::entity::ToolDef>>,
) -> Result<String, String> {
let tmpl = model
.chat_template(None)
.map_err(|e| format!("Chat template error: {e}"))?;
for (i, msg) in messages.iter().enumerate() { let mut chat_msgs: Vec<LlamaChatMessage> = Vec::new();
match msg.role.as_str() {
"system" => { for msg in messages {
let mut content = msg.content.clone().unwrap_or_default(); let content = msg.content.clone().unwrap_or_default();
// Inject tools into the system message (first occurrence) let role = msg.role.clone();
if i == 0 {
if let Some(tools_list) = tools { // Build content with tool calls for assistant messages
if !tools_list.is_empty() { let full_content = if role == "assistant" {
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 { if let Some(tcs) = &msg.tool_calls {
let mut asst = format!("<|im_start|>assistant\n{}", content); let mut c = content;
for tc in tcs { for tc in tcs {
let args: serde_json::Value = let args: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or_default(); serde_json::from_str(&tc.function.arguments).unwrap_or_default();
asst.push_str(&format!( let args_str = serde_json::to_string(&args).unwrap_or_default();
"<tool_call>\n<function={}>\n", c.push_str(&format!(
tc.function.name "<tool_call>\n<function={}>\n{}\n</function>\n</tool_call>",
tc.function.name, args_str
)); ));
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));
} }
} c
asst.push_str("</function>\n</tool_call>");
}
asst.push_str("<|im_end|>\n");
prompt.push_str(&asst);
} else { } else {
prompt.push_str(&format!(
"<|im_start|>assistant\n{}<|im_end|>\n",
content content
));
}
}
_ => {
let content = msg.content.as_deref().unwrap_or("");
prompt.push_str(&format!("<|im_start|>user\n{}<|im_end|>\n", content));
}
} }
} else {
content
};
let llama_role = match role.as_str() {
"tool" => "tool".to_string(),
r => r.to_string(),
};
chat_msgs.push(
LlamaChatMessage::new(llama_role, full_content)
.map_err(|e| format!("Message error: {e}"))?,
);
} }
// Generation prompt: non-thinking mode // Apply chat template with generation prompt (add_ass = true)
prompt.push_str("<|im_start|>assistant\n<think>\n\n</think>\n\n"); let mut result = model
prompt .apply_chat_template(&tmpl, &chat_msgs, true)
.map_err(|e| format!("Template error: {e}"))?;
// Append think trigger for MiniCPM5 thinking mode:
// <|im_start|>assistant\n<think>\n → model generates reasoning + </think> + answer
result.push_str("<think>\n");
Ok(result)
} }
/// Parameters for building a [`LlamaSampler`] chain. /// Parameters for building a [`LlamaSampler`] chain.
@@ -135,7 +110,6 @@ pub fn build_sampler(params: &SamplerParams) -> LlamaSampler {
let seed = params.seed; let seed = params.seed;
let mut samplers: Vec<LlamaSampler> = Vec::new(); let mut samplers: Vec<LlamaSampler> = Vec::new();
// Repetition/frequency/presence penalties
let repeat = repeat_penalty.unwrap_or(1.0); let repeat = repeat_penalty.unwrap_or(1.0);
let freq = frequency_penalty.unwrap_or(0.0); let freq = frequency_penalty.unwrap_or(0.0);
let present = presence_penalty.unwrap_or(0.0); let present = presence_penalty.unwrap_or(0.0);
@@ -143,22 +117,18 @@ pub fn build_sampler(params: &SamplerParams) -> LlamaSampler {
samplers.push(LS::penalties(64, repeat, freq, present)); samplers.push(LS::penalties(64, repeat, freq, present));
} }
// top_k
if let Some(k) = top_k { if let Some(k) = top_k {
samplers.push(LS::top_k(k as i32)); samplers.push(LS::top_k(k as i32));
} }
// top_p
if let Some(p) = top_p { if let Some(p) = top_p {
samplers.push(LS::top_p(p, 1)); samplers.push(LS::top_p(p, 1));
} }
// min_p
if let Some(p) = min_p { if let Some(p) = min_p {
samplers.push(LS::min_p(p, 1)); samplers.push(LS::min_p(p, 1));
} }
// Temperature + final selector
let temp = temperature.unwrap_or(0.0); let temp = temperature.unwrap_or(0.0);
if temp <= 0.0 { if temp <= 0.0 {
samplers.push(LS::greedy()); samplers.push(LS::greedy());
@@ -173,7 +143,6 @@ pub fn build_sampler(params: &SamplerParams) -> LlamaSampler {
LlamaSampler::chain_simple(samplers) LlamaSampler::chain_simple(samplers)
} }
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// TEXT PROCESSING // TEXT PROCESSING
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
@@ -264,7 +233,6 @@ pub fn parse_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
continue; continue;
} }
} }
// Save last param
if let Some(p) = current_param.take() { if let Some(p) = current_param.take() {
args_map.insert(p, serde_json::Value::String(current_value.trim().to_string())); args_map.insert(p, serde_json::Value::String(current_value.trim().to_string()));
} }
@@ -283,11 +251,8 @@ pub fn parse_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
idx = end; idx = end;
} }
// Remove tool_call blocks from the text
clean = clean.replace("<tool_call>", "").replace("</tool_call>", ""); 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(); clean = clean.trim().to_string();
// Strip remaining XML tags that aren't part of clean
let cleaned = clean_text(&clean); let cleaned = clean_text(&clean);
(cleaned, tool_calls) (cleaned, tool_calls)
+3 -3
View File
@@ -4,8 +4,8 @@
use std::sync::LazyLock; use std::sync::LazyLock;
const DEFAULT_MODEL_PATH: &str = "/models/MiniCPM-V-4.6-Q4_K_M.gguf"; const DEFAULT_MODEL_PATH: &str = "/models/MiniCPM5-1B-Claude-Opus-Fable5-V2-Thinking-Q8_0.gguf";
pub const MODEL_ID: &str = "minicpm-v-4.6"; pub const MODEL_ID: &str = "minicpm5-1b-fable5-v2-thinking";
/// Application configuration loaded at startup from environment variables. /// Application configuration loaded at startup from environment variables.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -44,7 +44,7 @@ impl AppConfig {
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(8080), .unwrap_or(8080),
log_level: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()), log_level: std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string()),
n_ctx: 2048, n_ctx: 8192,
n_batch: 512, n_batch: 512,
n_threads: 4, n_threads: 4,
} }
+10
View File
@@ -219,6 +219,16 @@ impl LlamaEngine {
let mut current = inner.sample(sampler); let mut current = inner.sample(sampler);
// Skip leading EOS tokens (like <|im_end|> as first token)
while output.is_empty() && self.model.is_eog_token(current) {
let pos = input_tokens.len() as i32 + output.len() as i32;
if let Err(e) = inner.decode(current, pos) {
tracing::info!(" Decode error: {e}");
break;
}
current = inner.sample(sampler);
}
for _ in 0..max_tokens { for _ in 0..max_tokens {
if self.model.is_eog_token(current) { if self.model.is_eog_token(current) {
break; break;
+35 -1
View File
@@ -26,7 +26,8 @@ pub async fn chat_completions(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let max_tokens = req.max_tokens.unwrap_or(256).min(1024); let max_tokens = req.max_tokens.unwrap_or(256).min(1024);
let stop = req.stop.clone().unwrap_or_default(); let stop = req.stop.clone().unwrap_or_default();
let prompt = chat::build_prompt(&req.messages, &req.tools); let prompt = chat::build_prompt(&state.engine.model, &req.messages, &req.tools)
.map_err(|e| AppError::LlmError(e))?;
// Tokenize // Tokenize
let input_tokens = state let input_tokens = state
@@ -186,6 +187,39 @@ async fn handle_streaming(
break; break;
} }
// Skip leading EOS tokens (like <|im_end|> at start of generation)
if state.engine.is_eog(current) && text_buf.is_empty() {
// safety bound: don't skip more than 10
if count >= max_tokens || count > 10 {
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;
}
count += 1;
let pos = input_tokens.len() as i32 + count as i32;
if let Err(e) = inner.decode(current, pos) {
info!(" Decode error: {e}");
break;
}
current = inner.sample(&mut sampler);
continue;
}
if state.engine.is_eog(current) { if state.engine.is_eog(current) {
let reason = if has_tools && text_buf.contains("<tool_call>") { let reason = if has_tools && text_buf.contains("<tool_call>") {
"tool_calls" "tool_calls"
+1 -1
View File
@@ -8,6 +8,6 @@ use crate::domain::entity::HealthResponse;
pub async fn health_check() -> Json<HealthResponse> { pub async fn health_check() -> Json<HealthResponse> {
Json(HealthResponse { Json(HealthResponse {
status: "ok".into(), status: "ok".into(),
model: format!("{MODEL_ID}-q4_k_m"), model: format!("{MODEL_ID}-q8_0"),
}) })
} }