Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a63ff1601 | ||
|
|
59c77108a5 | ||
|
|
495b9ed126 | ||
|
|
254532458b | ||
|
|
7c8f747faf | ||
|
|
d8425ea3b3 | ||
|
|
14032db705 | ||
|
|
2a5ab8b6d5 | ||
|
|
7e717cc808 |
No files matched your search
@@ -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 }}"
|
||||||
|
}
|
||||||
@@ -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(
|
if let Some(tcs) = &msg.tool_calls {
|
||||||
"\n\n# Tools\n\nYou have access to the following functions:\n\n<tools>",
|
let mut c = content;
|
||||||
);
|
for tc in tcs {
|
||||||
for tool in tools_list {
|
let args: serde_json::Value =
|
||||||
tools_text.push('\n');
|
serde_json::from_str(&tc.function.arguments).unwrap_or_default();
|
||||||
tools_text.push_str(
|
let args_str = serde_json::to_string(&args).unwrap_or_default();
|
||||||
&serde_json::to_string(tool).unwrap_or_default(),
|
c.push_str(&format!(
|
||||||
);
|
"<tool_call>\n<function={}>\n{}\n</function>\n</tool_call>",
|
||||||
}
|
tc.function.name, args_str
|
||||||
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
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
content
|
||||||
}
|
}
|
||||||
_ => {
|
} else {
|
||||||
let content = msg.content.as_deref().unwrap_or("");
|
content
|
||||||
prompt.push_str(&format!("<|im_start|>user\n{}<|im_end|>\n", 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
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user