Compare commits

Author SHA1 Message Date
Asep Haryana 67861f384b fix(stream): add anti-buffer headers for real SSE streaming
Notify Parent Repo / dispatch (push) Canceled after 0s
- Add X-Accel-Buffering: no, Cache-Control: no-cache, Connection: keep-alive
- Prevents Traefik/proxy from buffering SSE events
- Each token is flushed immediately as generated
2026-07-26 19:13:00 +07:00
Asep Haryana 5cba76d280 fix(stream): separate reasoning_content from content in SSE streaming
- Before </think>: tokens sent as delta.reasoning_content
- After </think>: tokens sent as delta.content
- Handles </think> split across token boundaries
- Clean <|im_start|>/<|im_end|>/<think>/</think> from streamed text
- Normal models (no thinking) send all as content as before
2026-07-26 19:04:29 +07:00
Asep Haryana 6ff31b5f62 feat: render GGUF Jinja template via minijinja crate
- Replaced manual prompt building with GGUF's chat template rendered through minijinja (Rust Jinja2 engine)
- Simplified template: removed multi-step tool detection, reasoning extraction (not needed at template level)
- Added reasoning_content separation: clean_text() returns (reasoning, answer) tuple
- Added reasoning_content field to ResponseMessage and SseDelta for OpenAI-compatible output
- Embedded template at build time via include_str! from templates/chat_template.jinja
- Built-in support for enable_thinking, tool_definitions, tool_calls, tool_response
2026-07-26 18:21:35 +07:00
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
10 changed files with 367 additions and 115 deletions
+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 }}"
}
Generated
+17
View File
@@ -568,6 +568,7 @@ dependencies = [
"chrono",
"futures",
"llama-cpp-2",
"minijinja",
"serde",
"serde_json",
"thiserror 1.0.69",
@@ -615,12 +616,28 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memo-map"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minijinja"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb3d648e68cea56d9858d535ee28f9538404e2dd8cb08ed0bd05dca379477f39"
dependencies = [
"memo-map",
"serde",
]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
+1
View File
@@ -6,6 +6,7 @@ edition = "2021"
[dependencies]
# LLM inference
llama-cpp-2 = "0.1"
minijinja = "2"
# HTTP server
axum = { version = "0.8", features = ["json"] }
@@ -0,0 +1,53 @@
{{- bos_token }}
{%- if tools %}
{%- set tool_definitions %}
{{- "# Tools\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
{%- for tool in tools %}
{{- "\n" }}
{{- tool | tojson }}
{%- endfor %}
{{- '\n</tools>\n\nTool usage guidelines:\n- You may call zero or more functions. If no function calls are needed, just answer normally.\n- When calling a function, use: <function name="name"><param name="key">value</param></function>' }}
{%- endset %}
{{- '<|im_start|>system\n' }}
{%- if messages[0].role == 'system' %}
{{- messages[0].content + '\n\n' + tool_definitions }}
{%- else %}
{{- tool_definitions }}
{%- endif %}
{{- '<|im_end|>\n' }}
{%- else %}
{%- if messages[0].role == 'system' %}
{{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
{%- endif %}
{%- endif %}
{%- for message in messages %}
{%- set content = message.content %}
{%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
{{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
{%- elif message.role == "assistant" %}
{%- if message.tool_calls %}
{%- set content = message.content %}
{%- for tool_call in message.tool_calls %}
{%- if tool_call.type == "function" %}
{%- set content = content + "\n<tool_call>\n<function=" + tool_call.function.name + ">\n" + (tool_call.function.arguments | tojson) + "\n</function>\n</tool_call>" %}
{%- endif %}
{%- endfor %}
{{- '<|im_start|>assistant\n' + content + '<|im_end|>\n' }}
{%- else %}
{{- '<|im_start|>assistant\n' + content + '<|im_end|>\n' }}
{%- endif %}
{%- elif message.role == "tool" %}
{{- '<|im_start|>user\n<tool_response>\n' + content + '\n</tool_response><|im_end|>\n' }}
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}
{{- '<|im_start|>assistant\n' }}
{%- if enable_thinking is defined %}
{%- if enable_thinking is false %}
{{- '<think>\n\n</think>\n\n' }}
{%- elif enable_thinking is true %}
{{- '<think>\n' }}
{%- endif %}
{%- endif %}
{%- endif %}
+106 -100
View File
@@ -1,97 +1,95 @@
//! Chat completion use cases.
//!
//! Orchestrates prompt building, sampler construction, and output parsing.
//! These are pure functions with no framework dependencies.
//! Orchestrates prompt building using the model's baked-in Jinja template
//! via the `minijinja` crate, sampler construction, and output parsing.
use std::collections::HashMap;
use minijinja::{Environment, Value};
use llama_cpp_2::sampling::LlamaSampler;
use crate::domain::entity::{ChatMessage, ChatRequest, ToolCall, ToolCallFunction, ToolDef};
use crate::domain::entity::{ChatMessage, ChatRequest, ToolCall, ToolCallFunction};
/// Build a prompt string from conversation messages and optional tool definitions.
/// Build a prompt from messages using the GGUF's Jinja chat template.
///
/// 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();
/// Renders the model's baked-in template via minijinja, passing the message
/// history, optional tool definitions, and generation-prompt switches.
pub fn build_prompt(
model: &llama_cpp_2::model::LlamaModel,
messages: &[ChatMessage],
_tools: &Option<Vec<crate::domain::entity::ToolDef>>,
) -> Result<String, String> {
// Load the GGUF's chat template (embedded at crate build time)
let template_str = include_str!("templates/chat_template.jinja");
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);
let mut env = Environment::new();
env.add_template("chat", template_str)
.map_err(|e| format!("Template add error: {e}"))?;
// Register tojson filter (safe: Rust serde_json defaults to ensure_ascii=false)
env.add_filter("tojson", |value: &Value| -> String {
serde_json::to_string(value).unwrap_or_default()
});
let tmpl = env.get_template("chat")
.map_err(|e| format!("Template get error: {e}"))?;
// Build messages as serde_json::Value for minijinja
let mut msgs_val: Vec<Value> = Vec::new();
for msg in messages {
let mut m: HashMap<String, Value> = HashMap::new();
m.insert("role".into(), Value::from(msg.role.clone()));
let content = msg.content.clone().unwrap_or_default();
// For assistant messages, check if there are tool_calls
if msg.role == "assistant" {
if let Some(tcs) = &msg.tool_calls {
// Serialise tool calls per the template's expected format
let tcs_val: Vec<Value> = tcs.iter().map(|tc| {
let args: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or_default();
Value::from_serialize(&serde_json::json!({
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": args,
}
}
}
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));
}))
}).collect();
m.insert("tool_calls".into(), Value::from(tcs_val));
}
}
// Handle tool role messages
if msg.role == "tool" {
// Wrap in tool_response as the template expects
let wrapped = format!("<tool_response>\n{}\n</tool_response>", content);
m.insert("content".into(), Value::from(wrapped));
} else {
m.insert("content".into(), Value::from(content));
}
msgs_val.push(Value::from(m));
}
// Generation prompt: non-thinking mode
prompt.push_str("<|im_start|>assistant\n<think>\n\n</think>\n\n");
prompt
// BOS token for sentencepiece / unigram models
let bos_token: &str = "<s>";
// Build context
let mut ctx: HashMap<String, Value> = HashMap::new();
ctx.insert("bos_token".into(), Value::from(bos_token));
ctx.insert("messages".into(), Value::from(msgs_val));
ctx.insert("add_generation_prompt".into(), Value::from(true));
ctx.insert("enable_thinking".into(), Value::from(true));
// Render
let result = tmpl
.render(&ctx)
.map_err(|e| format!("Template render error: {e}"))?;
Ok(result)
}
/// Parameters for building a [`LlamaSampler`] chain.
@@ -135,7 +133,6 @@ pub fn build_sampler(params: &SamplerParams) -> LlamaSampler {
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);
@@ -143,22 +140,18 @@ pub fn build_sampler(params: &SamplerParams) -> LlamaSampler {
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());
@@ -173,19 +166,36 @@ pub fn build_sampler(params: &SamplerParams) -> LlamaSampler {
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()
/// Remove special tokens from generated text and separate reasoning.
///
/// For thinking models, returns (reasoning, cleaned_answer).
pub fn clean_text(text: &str) -> (String, String) {
let text = text.replace("<|im_end|>", "")
.replace("<|im_start|>", "");
// Separate reasoning (between <think>/</think>) from answer
let text = text.trim();
let (reasoning, answer) = if let Some(close_idx) = text.find("</think>") {
let reasoning = text[..close_idx].trim()
.trim_start_matches("<think>")
.trim()
.to_string();
let answer = text[close_idx + 8..].trim().to_string();
(reasoning, answer)
} else if text.contains("<think>") {
// Still thinking — everything is reasoning
let reasoning = text.trim_start_matches("<think>").trim().to_string();
(reasoning, String::new())
} else {
(String::new(), text.to_string())
};
let answer = answer.replace("<think>", "").replace("</think>", "");
(reasoning, answer.trim().to_string())
}
/// Parse tool calls from generated text in the format:
@@ -264,7 +274,6 @@ pub fn parse_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
continue;
}
}
// Save last param
if let Some(p) = current_param.take() {
args_map.insert(p, serde_json::Value::String(current_value.trim().to_string()));
}
@@ -283,12 +292,9 @@ pub fn parse_tool_calls(text: &str) -> (String, Vec<ToolCall>) {
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);
let (_reasoning, cleaned) = clean_text(&clean);
(cleaned, tool_calls)
}
+3 -3
View File
@@ -4,8 +4,8 @@
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";
const DEFAULT_MODEL_PATH: &str = "/models/MiniCPM5-1B-Claude-Opus-Fable5-V2-Thinking-Q8_0.gguf";
pub const MODEL_ID: &str = "minicpm5-1b-fable5-v2-thinking";
/// Application configuration loaded at startup from environment variables.
#[derive(Debug, Clone)]
@@ -44,7 +44,7 @@ impl AppConfig {
.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_ctx: 8192,
n_batch: 512,
n_threads: 4,
}
+5
View File
@@ -96,8 +96,11 @@ pub struct Choice {
#[derive(Serialize)]
pub struct ResponseMessage {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
@@ -165,6 +168,8 @@ pub struct SseDelta {
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
}
+10
View File
@@ -219,6 +219,16 @@ impl LlamaEngine {
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 {
if self.model.is_eog_token(current) {
break;
+146 -11
View File
@@ -5,6 +5,7 @@ use std::sync::Arc;
use axum::extract::State;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::http::HeaderMap;
use axum::{Json, response::{IntoResponse, Response}};
use chrono::Utc;
use tokio::sync::mpsc;
@@ -26,7 +27,8 @@ pub async fn chat_completions(
) -> 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);
let prompt = chat::build_prompt(&state.engine.model, &req.messages, &req.tools)
.map_err(|e| AppError::LlmError(e))?;
// Tokenize
let input_tokens = state
@@ -71,7 +73,8 @@ async fn handle_non_streaming(
.generate(&input_tokens, &mut sampler, max_tokens, &stop)
.await?;
let (output_text, tool_calls) = chat::parse_tool_calls(&chat::clean_text(&raw_text));
let (reasoning, cleaned) = chat::clean_text(&raw_text);
let (output_text, tool_calls) = chat::parse_tool_calls(&cleaned);
let completion_tokens = output_tokens.len() as u32;
info!(" {} generated tokens", completion_tokens);
@@ -84,6 +87,8 @@ async fn handle_non_streaming(
"length"
};
let reasoning_opt = if reasoning.is_empty() { None } else { Some(reasoning) };
Ok(Json(ChatResponse {
id: chat_id,
object: "chat.completion".into(),
@@ -94,6 +99,7 @@ async fn handle_non_streaming(
message: ResponseMessage {
role: "assistant".into(),
content: Some(output_text),
reasoning_content: reasoning_opt,
tool_calls: if tool_calls.is_empty() {
None
} else {
@@ -140,6 +146,7 @@ async fn handle_streaming(
role: Some("assistant".into()),
content: None,
tool_calls: None,
reasoning_content: None,
},
finish_reason: None,
}],
@@ -162,6 +169,8 @@ async fn handle_streaming(
let mut count = 0u32;
let mut text_buf = String::new();
let mut sent_len: usize = 0; // how many chars of text_buf have been sent
let mut think_done: bool = false; // true once </think> seen
let mut current = inner.sample(&mut sampler);
loop {
@@ -177,6 +186,7 @@ async fn handle_streaming(
role: None,
content: None,
tool_calls: None,
reasoning_content: None,
},
finish_reason: Some("length".into()),
}],
@@ -186,6 +196,40 @@ async fn handle_streaming(
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,
reasoning_content: 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) {
let reason = if has_tools && text_buf.contains("<tool_call>") {
"tool_calls"
@@ -203,6 +247,7 @@ async fn handle_streaming(
role: None,
content: None,
tool_calls: None,
reasoning_content: None,
},
finish_reason: Some(reason.into()),
}],
@@ -213,9 +258,97 @@ async fn handle_streaming(
}
let piece = state.engine.decode_token(current);
let content = chat::clean_text(&piece);
if !content.is_empty() {
// Push into buffer
text_buf.push_str(&piece);
// Detect </think> transition
if !think_done && text_buf.contains("</think>") {
think_done = true;
}
// Find new text since last send
let new_text = &text_buf[sent_len..]; // everything not yet streamed
if new_text.is_empty() {
// Nothing new to send; skip straight to decode
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);
continue;
}
// Strip special tokens from the NEW text chunk
// Split at </think> if present — before goes to reasoning, after to content
let (reasoning_part, content_part) = if let Some(pos) = new_text.find("</think>") {
let before = new_text[..pos]
.replace("<|im_end|>", "")
.replace("<|im_start|>", "")
.replace("<think>", "")
.trim()
.to_string();
let after = new_text[pos + 8..]
.replace("<|im_end|>", "")
.replace("<|im_start|>", "")
.replace("<think>", "")
.trim()
.to_string();
think_done = true;
(Some(before), after)
} else if think_done {
let cleaned = new_text
.replace("<|im_end|>", "")
.replace("<|im_start|>", "")
.replace("<think>", "")
.trim()
.to_string();
(None, cleaned)
} else {
let cleaned = new_text
.replace("<|im_end|>", "")
.replace("<|im_start|>", "")
.replace("<think>", "")
.trim()
.to_string();
(Some(cleaned), String::new())
};
// Send reasoning part (before </think>, or entire text if still thinking)
if let Some(ref r) = reasoning_part {
if !r.is_empty() {
let delta = SseDelta {
role: None,
content: None,
reasoning_content: Some(r.clone()),
tool_calls: None,
};
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,
finish_reason: None,
}],
})
.unwrap();
let _ = tx.send(Ok(Event::default().data(chunk))).await;
}
}
// Send content part (after </think>, or never if model doesn't think)
if !content_part.is_empty() {
let delta = SseDelta {
role: None,
content: Some(content_part),
reasoning_content: None,
tool_calls: None,
};
let chunk = serde_json::to_string(&SseChunk {
id: chat_id.clone(),
object: "chat.completion.chunk".into(),
@@ -223,11 +356,7 @@ async fn handle_streaming(
model: model_name.clone(),
choices: vec![SseChoice {
index: 0,
delta: SseDelta {
role: None,
content: Some(content.clone()),
tool_calls: None,
},
delta,
finish_reason: None,
}],
})
@@ -237,7 +366,7 @@ async fn handle_streaming(
}
}
text_buf.push_str(&piece);
sent_len = text_buf.len();
// Check stop sequences
let mut stop_now = false;
@@ -259,6 +388,7 @@ async fn handle_streaming(
role: None,
content: None,
tool_calls: None,
reasoning_content: None,
},
finish_reason: Some("stop".into()),
}],
@@ -284,6 +414,7 @@ async fn handle_streaming(
role: None,
content: None,
tool_calls: None,
reasoning_content: None,
},
finish_reason: Some("tool_calls".into()),
}],
@@ -305,6 +436,10 @@ async fn handle_streaming(
});
let stream = ReceiverStream::new(rx);
let mut headers = HeaderMap::new();
headers.insert("X-Accel-Buffering", "no".parse().unwrap());
headers.insert("Cache-Control", "no-cache".parse().unwrap());
headers.insert("Connection", "keep-alive".parse().unwrap());
let sse = Sse::new(stream).keep_alive(KeepAlive::default());
Ok(sse.into_response())
Ok((headers, sse).into_response())
}
+1 -1
View File
@@ -8,6 +8,6 @@ use crate::domain::entity::HealthResponse;
pub async fn health_check() -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok".into(),
model: format!("{MODEL_ID}-q4_k_m"),
model: format!("{MODEL_ID}-q8_0"),
})
}