use std::io::{BufRead, Read, Write};
use anyhow::{Context, Result};
use clap::Parser;
use serde_json::{json, Value};
use crate::http;
#[derive(Parser, Debug)]
pub struct ChatArgs {
#[arg(long, default_value = "http://127.0.0.1:8383")]
pub url: String,
#[arg(long, default_value = "default")]
pub model: String,
#[arg(long)]
pub system: Option<String>,
#[arg(long, default_value_t = 256)]
pub max_tokens: u32,
#[arg(long, default_value_t = 0.7)]
pub temperature: f32,
#[arg(long, default_value_t = 0.95)]
pub top_p: f32,
#[arg(long, default_value_t = true)]
pub stream: bool,
#[arg(long, default_value_t = false)]
pub no_stream: bool,
}
pub fn run_chat(args: ChatArgs) -> Result<()> {
let stream = args.stream && !args.no_stream;
let base = args.url.trim_end_matches('/');
let health = http::get(&format!("{base}/health"))
.with_context(|| format!("health check failed for {base}"))?;
if !(200..300).contains(&health.status) {
anyhow::bail!("server /health returned HTTP {}", health.status);
}
install_sigint_handler();
eprintln!("frink chat → {base} (/help for commands, Ctrl-C stops a turn, /quit to leave)");
if let Some(sys) = &args.system {
eprintln!("system: {sys}");
}
let mut messages: Vec<Value> = Vec::new();
if let Some(sys) = &args.system {
messages.push(json!({"role": "system", "content": sys}));
}
let mut gears = ThinkGears::fetch(base).unwrap_or_default();
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
loop {
eprint!("> ");
let _ = std::io::stderr().flush();
let mut line = String::new();
let n = stdin.lock().read_line(&mut line)?;
if n == 0 {
eprintln!();
break;
}
let line = line.trim();
if line.is_empty() {
continue;
}
if matches!(line, "/quit" | "/exit" | "/q") {
break;
}
if line == "/clear" {
messages.clear();
if let Some(sys) = &args.system {
messages.push(json!({"role": "system", "content": sys}));
}
eprintln!("(history cleared)");
continue;
}
if line == "/help" {
eprint!("{HELP}");
continue;
}
if line == "/stats" {
match http::get(&format!("{base}/v1/stats")) {
Ok(resp) => match serde_json::from_slice::<Value>(&resp.body) {
Ok(v) => eprintln!("{}", render_stats(&v)),
Err(e) => eprintln!("(could not read /v1/stats: {e})"),
},
Err(e) => eprintln!("(could not reach /v1/stats: {e:#})"),
}
continue;
}
if let Some(rest) = line.strip_prefix("/think") {
let rest = rest.trim();
match gears.select(rest) {
Ok(chosen) => eprintln!("(thinking: {chosen})"),
Err(why) => eprintln!("({why})"),
}
continue;
}
if let Some(rest) = line.strip_prefix("/cache") {
let rest = rest.trim();
eprintln!("{}", cache_command(base, rest));
continue;
}
if line.starts_with('/') {
eprintln!("(unknown command {line}; /help lists them)");
continue;
}
messages.push(json!({"role": "user", "content": line}));
let mut body = json!({
"model": args.model,
"messages": messages,
"max_tokens": args.max_tokens,
"temperature": args.temperature,
"top_p": args.top_p,
"stream": stream,
});
if let Some(kwargs) = gears.selected_kwargs() {
body["chat_template_kwargs"] = kwargs;
}
let reply = if stream {
print_streamed_completion(base, &body, &mut stdout)?
} else {
let text = post_completion(base, &body)?;
print!("{text}");
stdout.flush()?;
println!();
text
};
messages.push(json!({"role": "assistant", "content": reply}));
}
Ok(())
}
static GENERATING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static INTERRUPTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
struct Generating;
impl Generating {
fn begin() -> Self {
INTERRUPTED.store(false, std::sync::atomic::Ordering::SeqCst);
GENERATING.store(true, std::sync::atomic::Ordering::SeqCst);
Generating
}
}
impl Drop for Generating {
fn drop(&mut self) {
GENERATING.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
fn interrupted() -> bool {
INTERRUPTED.load(std::sync::atomic::Ordering::SeqCst)
}
extern "C" fn on_sigint(_sig: libc::c_int) {
if GENERATING.load(std::sync::atomic::Ordering::SeqCst) {
INTERRUPTED.store(true, std::sync::atomic::Ordering::SeqCst);
} else {
unsafe { libc::_exit(130) };
}
}
fn install_sigint_handler() {
unsafe {
libc::signal(libc::SIGINT, on_sigint as *const () as libc::sighandler_t);
}
}
const HELP: &str = "\
/help this list
/clear forget the conversation (keeps --system)
/stats what the server is doing right now
/think [gear] cycle, or set, the server's advertised thinking gear
/cache [tokens] show the KV pool, or resize it to N tokens
/quit leave
";
#[derive(Debug, Default)]
struct ThinkGears {
supported: Vec<String>,
kwargs: std::collections::BTreeMap<String, Value>,
selected: Option<String>,
}
impl ThinkGears {
fn fetch(base: &str) -> Result<Self> {
let resp = http::get(&format!("{base}/v1/models"))?;
let body: Value = serde_json::from_slice(&resp.body).context("parse /v1/models")?;
Ok(Self::from_models(&body))
}
fn from_models(body: &Value) -> Self {
let first = body.pointer("/data/0").cloned().unwrap_or(Value::Null);
let supported: Vec<String> = first
.get("supported_reasoning_efforts")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let kwargs = first
.get("reasoning_effort_kwargs")
.and_then(Value::as_object)
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
ThinkGears {
supported,
kwargs,
selected: None,
}
}
fn select(&mut self, want: &str) -> std::result::Result<String, String> {
if self.supported.is_empty() {
return Err("this server advertises no thinking gears".to_string());
}
let next = if want.is_empty() {
let at = self
.selected
.as_ref()
.and_then(|s| self.supported.iter().position(|g| g == s));
match at {
Some(i) => self.supported[(i + 1) % self.supported.len()].clone(),
None => self.supported[0].clone(),
}
} else if self.supported.iter().any(|g| g == want) {
want.to_string()
} else {
return Err(format!(
"no gear {want:?}; this server has {}",
self.supported.join(", ")
));
};
self.selected = Some(next.clone());
Ok(next)
}
fn selected_kwargs(&self) -> Option<Value> {
let gear = self.selected.as_ref()?;
self.kwargs.get(gear).cloned()
}
}
fn render_stats(v: &Value) -> String {
let num = |p: &str| -> String {
match v.pointer(p) {
Some(Value::Number(n)) => format!("{n}"),
_ => "-".to_string(),
}
};
let mut out = format!(
" model {} ({})\n",
v.get("model").and_then(Value::as_str).unwrap_or("none"),
v.get("state").and_then(Value::as_str).unwrap_or("?"),
);
out.push_str(&format!(
" tokens decode {} tok/s prefill {} tok/s\n",
num("/throughput/decode_tps"),
num("/throughput/prefill_tps"),
));
out.push_str(&format!(
" requests {} active {} done p95 {} ms ttft {} ms\n",
num("/requests/active"),
num("/requests/completed"),
num("/requests/p95_ms"),
num("/requests/ttft_mean_ms"),
));
match v.pointer("/pools/kv_pages") {
Some(kv) if !kv.is_null() => out.push_str(&format!(
" kv pool {}/{} pages of {} tokens\n",
kv.get("used").and_then(Value::as_u64).unwrap_or(0),
kv.get("total").and_then(Value::as_u64).unwrap_or(0),
kv.get("page_size").and_then(Value::as_u64).unwrap_or(0),
)),
_ => out.push_str(" kv pool none (every request allocates privately)\n"),
}
if let Some(mem) = v.get("memory").filter(|m| !m.is_null()) {
out.push_str(&format!(
" memory {:.2} GiB ({})\n",
mem.get("bytes").and_then(Value::as_u64).unwrap_or(0) as f64 / (1 << 30) as f64,
mem.get("kind").and_then(Value::as_str).unwrap_or("?"),
));
}
out.trim_end().to_string()
}
fn cache_command(base: &str, arg: &str) -> String {
if arg.is_empty() {
return match http::get(&format!("{base}/v1/cache/status")) {
Ok(resp) => match serde_json::from_slice::<Value>(&resp.body) {
Ok(v) => render_cache_status(&v),
Err(e) => format!("(could not read /v1/cache/status: {e})"),
},
Err(e) => format!("(could not reach /v1/cache/status: {e:#})"),
};
}
let Ok(tokens) = arg.parse::<u64>() else {
return format!("(/cache takes a token count, not {arg:?})");
};
let body = json!({"kv": tokens});
let bytes = match serde_json::to_vec(&body) {
Ok(b) => b,
Err(e) => return format!("(could not encode the request: {e})"),
};
match http::exchange("POST", &format!("{base}/v1/cache/rebuild"), Some(&bytes)) {
Ok(resp) => {
let v: Value = serde_json::from_slice(&resp.body).unwrap_or(Value::Null);
match v.get("error").and_then(Value::as_str) {
Some(err) => format!("(refused: {err})"),
None => render_cache_status(&json!({"kv": v.get("kv")})),
}
}
Err(e) => format!("(could not reach /v1/cache/rebuild: {e:#})"),
}
}
fn render_cache_status(v: &Value) -> String {
match v.get("kv").filter(|kv| !kv.is_null()) {
Some(kv) => format!(
" kv pool {} pages of {} tokens = {} tokens",
kv.get("num_pages").and_then(Value::as_u64).unwrap_or(0),
kv.get("page_size").and_then(Value::as_u64).unwrap_or(0),
kv.get("num_tokens").and_then(Value::as_u64).unwrap_or(0),
),
None => " kv pool none; this server allocates per request \
(start it with FRINK_KV_POOL_BLOCKS)"
.to_string(),
}
}
fn post_completion(base: &str, body: &Value) -> Result<String> {
let bytes = serde_json::to_vec(body)?;
let body = http::exchange("POST", &format!("{base}/v1/chat/completions"), Some(&bytes))?
.ok_or_status()?;
let v: Value = serde_json::from_slice(&body).context("parse chat response")?;
extract_message_content(&v)
}
fn print_streamed_completion(base: &str, body: &Value, out: &mut impl Write) -> Result<String> {
let bytes = serde_json::to_vec(body)?;
let (status, mut reader) =
http::open("POST", &format!("{base}/v1/chat/completions"), Some(&bytes))?;
if !(200..300).contains(&status) {
let mut rest = String::new();
reader.read_to_string(&mut rest)?;
anyhow::bail!("HTTP {status}: {rest}");
}
let mut render = StreamRender::default();
let mut line = String::new();
let mut request_id: Option<String> = None;
let _generating = Generating::begin();
while reader.read_line(&mut line)? > 0 {
let trimmed = line.trim_end();
if let Some(data) = trimmed.strip_prefix("data: ") {
if data.trim() == "[DONE]" {
break;
}
if let Ok(v) = serde_json::from_str::<Value>(data) {
if request_id.is_none() {
request_id = stated_request_id(&v);
}
render.push_chunk(&v, out)?;
}
}
if interrupted() {
render.finish(out)?;
match &request_id {
Some(id) => match cancel_generation(base, id) {
Ok(()) => eprintln!("(interrupted; the server stopped generating)"),
Err(e) => eprintln!("(interrupted locally, but the cancel failed: {e:#})"),
},
None => eprintln!("(interrupted before the server named the request)"),
}
return Ok(render.assembled);
}
line.clear();
}
render.finish(out)?;
Ok(render.assembled)
}
fn stated_request_id(chunk: &Value) -> Option<String> {
chunk
.get("request_id")
.and_then(Value::as_str)
.map(str::to_string)
}
fn cancel_generation(base: &str, request_id: &str) -> Result<()> {
let body = serde_json::to_vec(&json!({"request_id": request_id}))?;
let resp = http::exchange("POST", &format!("{base}/v1/cancel"), Some(&body))?;
if !(200..300).contains(&resp.status) {
anyhow::bail!(
"HTTP {}: {}",
resp.status,
String::from_utf8_lossy(&resp.body)
);
}
Ok(())
}
#[derive(Default)]
struct StreamRender {
assembled: String,
thinking: bool,
}
impl StreamRender {
fn push_chunk(&mut self, v: &Value, out: &mut impl Write) -> Result<()> {
if let Some(thought) = v
.pointer("/choices/0/delta/reasoning_content")
.and_then(|c| c.as_str())
.filter(|s| !s.is_empty())
{
if !self.thinking {
self.thinking = true;
write!(out, "{DIM}")?;
}
write!(out, "{thought}")?;
out.flush()?;
}
if let Some(delta) = v
.pointer("/choices/0/delta/content")
.and_then(|c| c.as_str())
{
if self.thinking && !delta.is_empty() {
self.thinking = false;
write!(out, "{RESET}")?;
}
write!(out, "{delta}")?;
out.flush()?;
self.assembled.push_str(delta);
}
if self.assembled.is_empty() {
if let Ok(full) = extract_message_content(v) {
if self.thinking {
self.thinking = false;
write!(out, "{RESET}")?;
}
write!(out, "{full}")?;
out.flush()?;
self.assembled = full;
}
}
Ok(())
}
fn finish(&mut self, out: &mut impl Write) -> Result<()> {
if self.thinking {
self.thinking = false;
write!(out, "{RESET}")?;
}
writeln!(out)?;
Ok(())
}
}
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";
fn extract_message_content(v: &Value) -> Result<String> {
if let Some(s) = v
.pointer("/choices/0/message/content")
.and_then(|c| c.as_str())
{
return Ok(s.to_string());
}
if let Some(s) = v
.pointer("/choices/0/delta/content")
.and_then(|c| c.as_str())
{
return Ok(s.to_string());
}
anyhow::bail!("no choices[0].message.content in response: {v}");
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn thought(text: &str) -> Value {
json!({"choices": [{"delta": {"reasoning_content": text}}]})
}
fn content(text: &str) -> Value {
json!({"choices": [{"delta": {"content": text}}]})
}
fn render(chunks: &[Value]) -> (String, String) {
let mut out: Vec<u8> = Vec::new();
let mut r = StreamRender::default();
for c in chunks {
r.push_chunk(c, &mut out).expect("render");
}
r.finish(&mut out).expect("finish");
(String::from_utf8(out).expect("utf-8"), r.assembled)
}
#[test]
fn a_reasoning_models_thoughts_are_shown_rather_than_dropped() {
let (printed, _) = render(&[thought("weighing "), thought("it up"), content("Paris.")]);
assert!(printed.contains("weighing it up"), "{printed:?}");
assert!(printed.contains("Paris."));
}
#[test]
fn the_thought_is_never_part_of_the_answer_that_is_replayed() {
let (_, assembled) = render(&[thought("weighing it up"), content("Paris.")]);
assert_eq!(assembled, "Paris.");
}
#[test]
fn the_dim_run_is_opened_once_and_closed_before_the_answer() {
let (printed, _) = render(&[thought("a"), thought("b"), content("X"), content("Y")]);
assert_eq!(printed.matches(DIM).count(), 1, "{printed:?}");
let dim_at = printed.find(DIM).expect("opened");
let reset_at = printed.find(RESET).expect("closed");
let answer_at = printed.find('X').expect("answered");
assert!(dim_at < reset_at, "the run opens before it closes");
assert!(
reset_at < answer_at,
"the answer must not be inside the dim run: {printed:?}"
);
}
#[test]
fn a_stream_that_ends_mid_thought_still_resets_the_terminal() {
let (printed, assembled) = render(&[thought("weighing it up")]);
assert!(printed.ends_with("\x1b[0m\n"), "{printed:?}");
assert!(assembled.is_empty());
}
#[test]
fn a_plain_answer_is_printed_without_any_escapes() {
let (printed, assembled) = render(&[content("hello "), content("world")]);
assert_eq!(printed, "hello world\n");
assert_eq!(assembled, "hello world");
}
#[test]
fn the_gears_are_whatever_this_server_advertises() {
let body = json!({"data": [{
"supported_reasoning_efforts": ["off", "low", "high"],
"reasoning_effort_kwargs": {
"off": {"enable_thinking": false},
"low": {"enable_thinking": true, "reasoning_effort": "low"},
"high": {"enable_thinking": true, "reasoning_effort": "high"},
},
}]});
let mut gears = ThinkGears::from_models(&body);
assert_eq!(gears.supported, vec!["off", "low", "high"]);
assert!(gears.selected_kwargs().is_none());
assert_eq!(gears.select("").unwrap(), "off");
assert_eq!(gears.select("").unwrap(), "low");
assert_eq!(gears.select("").unwrap(), "high");
assert_eq!(gears.select("").unwrap(), "off", "and it wraps");
assert_eq!(gears.select("high").unwrap(), "high");
assert_eq!(
gears.selected_kwargs().unwrap()["reasoning_effort"],
json!("high")
);
}
#[test]
fn a_gear_this_server_does_not_have_is_refused_with_the_ones_it_does() {
let mut gears = ThinkGears::from_models(&json!({"data": [{
"supported_reasoning_efforts": ["on"],
"reasoning_effort_kwargs": {"on": {}},
}]}));
let err = gears.select("turbo").unwrap_err();
assert!(err.contains("turbo") && err.contains("on"), "{err}");
assert_eq!(gears.select("").unwrap(), "on");
assert_eq!(gears.selected_kwargs(), Some(json!({})));
}
#[test]
fn a_server_that_advertises_no_gears_has_no_think_command() {
let mut gears = ThinkGears::from_models(&json!({"data": [{"id": "m"}]}));
assert!(gears.select("").is_err());
assert!(gears.selected_kwargs().is_none());
}
#[test]
fn a_statistic_the_server_could_not_state_renders_as_a_dash() {
let rendered = render_stats(&json!({
"model": "m",
"state": "serving",
"throughput": {"decode_tps": 12.5, "prefill_tps": 0.0},
"requests": {
"active": 0, "completed": 3,
"p95_ms": Value::Null, "ttft_mean_ms": Value::Null,
},
"pools": {"kv_pages": Value::Null},
"memory": Value::Null,
}));
assert!(rendered.contains("p95 - ms"), "{rendered}");
assert!(rendered.contains("ttft - ms"), "{rendered}");
assert!(rendered.contains("decode 12.5"), "{rendered}");
assert!(
rendered.contains("none (every request allocates privately)"),
"an absent pool is not an empty one: {rendered}"
);
assert!(!rendered.contains("memory"), "absent memory prints nothing");
}
#[test]
fn stats_renders_the_pool_and_memory_when_the_server_has_them() {
let rendered = render_stats(&json!({
"model": "m",
"state": "serving",
"throughput": {"decode_tps": 1.0, "prefill_tps": 2.0},
"requests": {"active": 1, "completed": 2, "p95_ms": 30, "ttft_mean_ms": 10},
"pools": {"kv_pages": {"used": 4, "total": 64, "page_size": 256}},
"memory": {"bytes": 2u64 << 30, "kind": "pss"},
}));
assert!(rendered.contains("4/64 pages of 256 tokens"), "{rendered}");
assert!(rendered.contains("2.00 GiB (pss)"), "{rendered}");
}
#[test]
fn the_request_id_is_read_from_the_chunk_that_states_it() {
let first = json!({
"id": "chatcmpl-1",
"request_id": "chatcmpl-1",
"choices": [{"delta": {"role": "assistant"}}],
});
assert_eq!(stated_request_id(&first).as_deref(), Some("chatcmpl-1"));
let later = json!({"id": "chatcmpl-1", "choices": [{"delta": {"content": "hi"}}]});
assert_eq!(stated_request_id(&later), None);
}
#[test]
fn a_finished_turn_stops_being_interruptible_however_it_ended() {
assert!(!GENERATING.load(std::sync::atomic::Ordering::SeqCst));
{
let _g = Generating::begin();
assert!(GENERATING.load(std::sync::atomic::Ordering::SeqCst));
INTERRUPTED.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(interrupted());
}
assert!(
!GENERATING.load(std::sync::atomic::Ordering::SeqCst),
"the guard must clear the mark on drop"
);
let _g = Generating::begin();
assert!(!interrupted());
}
}