use std::time::Duration;
use serde_json::{json, Value};
use super::{
wake_prompt, ProviderKind, ProviderSession, SessionKind, WakeAdapter, WakeError, WakeLetter,
WakeOutcome,
};
pub const KIMI_SERVER_URL_ENV: &str = "M4A_KIMI_SERVER_URL";
pub const KIMI_SERVER_TOKEN_ENV: &str = "M4A_KIMI_SERVER_TOKEN";
pub struct KimiServerAdapter {
origin: Option<String>,
bearer: Option<String>,
}
impl KimiServerAdapter {
pub fn new(origin: Option<String>, bearer: Option<String>) -> Self {
let origin = origin
.map(|o| o.trim().trim_end_matches('/').to_string())
.filter(|o| is_loopback_http(o));
Self {
origin,
bearer: bearer.filter(|b| !b.is_empty()),
}
}
pub fn from_env() -> Self {
Self::new(
std::env::var(KIMI_SERVER_URL_ENV).ok(),
std::env::var(KIMI_SERVER_TOKEN_ENV).ok(),
)
}
}
impl KimiServerAdapter {
pub fn is_configured(&self) -> bool {
self.origin.is_some() && self.bearer.is_some()
}
pub fn into_parts(self) -> (Option<String>, Option<String>) {
(self.origin, self.bearer)
}
}
pub fn discover_local_server(home: Option<&std::path::Path>) -> Option<KimiServerAdapter> {
let home = home.map(std::path::Path::to_path_buf).or_else(|| {
std::env::var_os("KIMI_CODE_HOME")
.filter(|v| !v.is_empty())
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.map(|h| std::path::PathBuf::from(h).join(".kimi-code"))
})
})?;
let mut instances: Vec<(std::time::SystemTime, Value)> =
std::fs::read_dir(home.join("server").join("instances"))
.ok()?
.filter_map(Result::ok)
.filter_map(|item| {
let modified = item.metadata().and_then(|m| m.modified()).ok()?;
let value: Value =
serde_json::from_slice(&std::fs::read(item.path()).ok()?).ok()?;
Some((modified, value))
})
.collect();
instances.sort_by(|a, b| b.0.cmp(&a.0));
let token = std::fs::read_to_string(home.join("server.token")).ok()?;
let token = token.trim().to_string();
for (_, instance) in instances {
if let Some(pid) = instance["pid"].as_u64() {
if cfg!(target_os = "linux") && !std::path::Path::new(&format!("/proc/{pid}")).exists()
{
continue;
}
}
let Some(port) = instance["port"].as_u64() else {
continue;
};
let host = instance["host"].as_str().unwrap_or("127.0.0.1");
let host = if host == "0.0.0.0" || host.is_empty() {
"127.0.0.1"
} else {
host
};
let adapter =
KimiServerAdapter::new(Some(format!("http://{host}:{port}")), Some(token.clone()));
if adapter.is_configured() {
return Some(adapter);
}
}
None
}
pub fn prompt_body(text: &str) -> Value {
json!({"content": [{"type": "text", "text": text}]})
}
fn is_loopback_http(origin: &str) -> bool {
let Some(rest) = origin.strip_prefix("http://") else {
return false;
};
let host = rest.split('/').next().unwrap_or("");
let host = host.rsplit_once(':').map(|(h, _)| h).unwrap_or(host);
matches!(host, "127.0.0.1" | "localhost" | "[::1]")
}
fn session_path_ok(id: &str) -> bool {
!id.is_empty()
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
impl WakeAdapter for KimiServerAdapter {
fn kind(&self) -> SessionKind {
SessionKind::local(ProviderKind::KimiCode)
}
fn probe(&self, session: &ProviderSession) -> Result<(), WakeError> {
if self.origin.is_none() {
return Err(WakeError::Unavailable(
"kimi server url unset or not loopback".into(),
));
}
if self.bearer.is_none() {
return Err(WakeError::Unavailable("kimi server token unset".into()));
}
if !session_path_ok(&session.session_id) {
return Err(WakeError::Unavailable(
"kimi session id is not a plain id".into(),
));
}
Ok(())
}
fn wake(
&mut self,
session: &ProviderSession,
letter: &WakeLetter<'_>,
) -> Result<WakeOutcome, WakeError> {
self.probe(session)?;
let origin = self.origin.as_deref().unwrap_or_default();
let bearer = self.bearer.as_deref().unwrap_or_default();
let url = format!("{origin}/api/v1/sessions/{}/prompts", session.session_id);
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(20))
.build()
.map_err(|err| WakeError::Transport(err.to_string()))?;
let response = client
.post(url)
.bearer_auth(bearer)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(prompt_body(&wake_prompt(session, letter)).to_string())
.send()
.map_err(|err| WakeError::Unavailable(format!("kimi server: {}", err.without_url())))?;
let status = response.status();
let body: Value = response
.bytes()
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or(Value::Null);
if !status.is_success() || body["code"] != json!(0) {
let msg = body["msg"]
.as_str()
.unwrap_or("")
.chars()
.take(120)
.collect::<String>();
return Err(WakeError::Transport(format!(
"kimi prompt: http {} {msg}",
status.as_u16()
)));
}
match body["data"]["status"].as_str() {
Some("running" | "queued") => Ok(WakeOutcome::Delivered),
Some(other) => Err(WakeError::Transport(format!("kimi prompt status {other}"))),
None => Err(WakeError::Transport("kimi prompt: no status".into())),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::tests::{letter, session};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
fn fake(status: &'static str) -> (String, std::thread::JoinHandle<(String, String)>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let origin = format!("http://{}", listener.local_addr().unwrap());
let handle = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
let mut auth = String::new();
let mut len = 0usize;
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line == "\r\n" {
break;
}
let lower = line.to_ascii_lowercase();
if lower.starts_with("authorization:") {
auth = line.trim().to_string();
}
if let Some(v) = lower.strip_prefix("content-length:") {
len = v.trim().parse().unwrap();
}
}
let mut body = vec![0; len];
reader.read_exact(&mut body).unwrap();
let reply = format!(
"{{\"code\":0,\"msg\":\"success\",\"data\":{{\"prompt_id\":\"p\",\"status\":\"{status}\"}}}}"
);
let mut stream = stream;
write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{reply}",
reply.len()
)
.unwrap();
(
format!("{} {auth}", request_line.trim()),
String::from_utf8(body).unwrap(),
)
});
(origin, handle)
}
#[test]
fn posts_prompt_with_bearer_and_accepts_running() {
let (origin, server) = fake("running");
let mut adapter = KimiServerAdapter::new(Some(origin), Some("tok".into()));
let mut s = session(SessionKind::local(ProviderKind::KimiCode));
s.session_id = "session_abc-1".into();
assert_eq!(
adapter.wake(&s, &letter("hi")).unwrap(),
WakeOutcome::Delivered
);
let (head, body) = server.join().unwrap();
assert!(head.starts_with("POST /api/v1/sessions/session_abc-1/prompts HTTP/1.1"));
assert!(head.ends_with("Bearer tok"));
let body: Value = serde_json::from_str(&body).unwrap();
assert_eq!(body["content"][0]["type"], "text");
assert!(body["content"][0]["text"].as_str().unwrap().ends_with("hi"));
}
#[test]
fn blocked_is_an_error_and_remote_origin_is_refused() {
let (origin, server) = fake("blocked");
let mut adapter = KimiServerAdapter::new(Some(origin), Some("tok".into()));
let s = session(SessionKind::local(ProviderKind::KimiCode));
assert!(adapter.wake(&s, &letter("hi")).is_err());
server.join().unwrap();
let remote = KimiServerAdapter::new(Some("http://10.0.0.2:58627".into()), Some("t".into()));
assert!(matches!(remote.probe(&s), Err(WakeError::Unavailable(_))));
let mut bad = session(SessionKind::local(ProviderKind::KimiCode));
bad.session_id = "../x".into();
let ok = KimiServerAdapter::new(Some("http://127.0.0.1:1".into()), Some("t".into()));
assert!(ok.probe(&bad).is_err());
}
#[test]
#[ignore]
fn live_prompt() {
let mut adapter = KimiServerAdapter::new(
std::env::var("M4A_LIVE_KIMI_URL").ok(),
std::env::var("M4A_LIVE_KIMI_TOKEN").ok(),
);
let mut s = session(SessionKind::local(ProviderKind::KimiCode));
s.session_id = std::env::var("M4A_LIVE_KIMI_SESSION").unwrap();
assert_eq!(
adapter.wake(&s, &letter("live probe")).unwrap(),
WakeOutcome::Delivered
);
}
#[test]
fn discovers_live_loopback_instance_and_skips_dead_pids() {
let home = std::env::temp_dir().join(format!("m4a-kimi-home-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let inst = home.join("server").join("instances");
std::fs::create_dir_all(&inst).unwrap();
assert!(discover_local_server(Some(&home)).is_none());
std::fs::write(home.join("server.token"), "tok\n").unwrap();
std::fs::write(
inst.join("dead.json"),
r#"{"pid":4000000000,"host":"127.0.0.1","port":1}"#,
)
.unwrap();
if cfg!(target_os = "linux") {
assert!(discover_local_server(Some(&home)).is_none());
}
std::fs::write(
inst.join("live.json"),
format!(
r#"{{"pid":{},"host":"127.0.0.1","port":58627}}"#,
std::process::id()
),
)
.unwrap();
let (url, token) = discover_local_server(Some(&home)).unwrap().into_parts();
assert_eq!(url.as_deref(), Some("http://127.0.0.1:58627"));
assert_eq!(token.as_deref(), Some("tok"));
let _ = std::fs::remove_dir_all(&home);
}
}