AkurAI Build
Menu

Public repository / 16baa88e7934816edd8435507847f72fc196163f

AkurAI-Router / src/claude_cli.rs

Latest change 16baa88e7934816edd8435507847f72fc196163f - OLI-301: claude-cli runner backend + static routes.conf (stale window 0) by Olafur Bui

use std::io::Write;
use std::process::{Command, Stdio};

use crate::config::Config;
use crate::http::{self, Request};
use crate::json::{self, Json};
use crate::util::now_secs;

const ENV_PASSTHROUGH: [&str; 2] = ["PATH", "LANG"];

pub struct CliPrompt {
    pub system: String,
    pub prompt: String,
}

fn message_text(content: Option<&Json>) -> String {
    match content {
        Some(Json::String(text)) => text.clone(),
        Some(Json::Array(parts)) => parts
            .iter()
            .filter_map(|part| part.get_str("text"))
            .collect::<Vec<_>>()
            .join("\n"),
        _ => String::new(),
    }
}

pub fn build_prompt(body: &Json) -> CliPrompt {
    let mut system = Vec::new();
    let mut turns = Vec::new();
    if let Some(Json::Array(messages)) = body.get("messages") {
        for message in messages {
            let text = message_text(message.get("content"));
            if text.trim().is_empty() {
                continue;
            }
            match message.get_str("role").unwrap_or("user") {
                "system" | "developer" => system.push(text),
                "assistant" => turns.push(format!("Assistant: {text}")),
                _ => turns.push(format!("User: {text}")),
            }
        }
    }
    let prompt = if turns.len() == 1 && turns[0].starts_with("User: ") {
        turns[0]["User: ".len()..].to_string()
    } else {
        turns.join("\n\n")
    };
    CliPrompt {
        system: system.join("\n\n"),
        prompt,
    }
}

fn result_event(stream_json: &str) -> Option<Json> {
    stream_json
        .lines()
        .filter_map(|line| json::parse(line).ok())
        .rfind(|event| event.get_str("type") == Some("result"))
}

fn usage_number(usage: Option<&Json>, key: &str) -> u64 {
    match usage.and_then(|u| u.get(key)) {
        Some(Json::Number(n)) => n.parse().unwrap_or(0),
        _ => 0,
    }
}

pub fn stream_json_to_completion(model: &str, stream_json: &str) -> Result<String, String> {
    let event = result_event(stream_json).ok_or("claude CLI produced no result event")?;
    let is_error = event.get_bool("is_error").unwrap_or(false);
    let text = event.get_str("result").unwrap_or("");
    if is_error || event.get_str("terminal_reason") == Some("api_error") {
        return Err(format!("claude CLI error: {text}"));
    }
    let usage = event.get("usage");
    let prompt_tokens = usage_number(usage, "input_tokens")
        + usage_number(usage, "cache_creation_input_tokens")
        + usage_number(usage, "cache_read_input_tokens");
    let completion_tokens = usage_number(usage, "output_tokens");
    let finish = if event.get_str("stop_reason") == Some("max_tokens") {
        "length"
    } else {
        "stop"
    };
    let mut message = Json::object();
    message.set("role", Json::String("assistant".to_string()));
    message.set("content", Json::String(text.to_string()));
    let mut choice = Json::object();
    choice.set("index", Json::Number("0".to_string()));
    choice.set("message", message);
    choice.set("finish_reason", Json::String(finish.to_string()));
    let mut usage_out = Json::object();
    usage_out.set("prompt_tokens", Json::Number(prompt_tokens.to_string()));
    usage_out.set(
        "completion_tokens",
        Json::Number(completion_tokens.to_string()),
    );
    usage_out.set(
        "total_tokens",
        Json::Number((prompt_tokens + completion_tokens).to_string()),
    );
    let mut root = Json::object();
    root.set("id", Json::String(format!("chatcmpl-cli-{}", now_secs())));
    root.set("object", Json::String("chat.completion".to_string()));
    root.set("created", Json::Number(now_secs().to_string()));
    root.set("model", Json::String(model.to_string()));
    root.set("choices", Json::Array(vec![choice]));
    root.set("usage", usage_out);
    Ok(root.stringify())
}

fn run_cli(cfg: &Config, model: &str, prompt: &CliPrompt) -> Result<String, String> {
    let mut cmd = Command::new(&cfg.claude_cli_bin);
    cmd.env_clear()
        .env("HOME", &cfg.claude_cli_home)
        .current_dir(&cfg.data_dir)
        .args(["-p", "--output-format", "stream-json", "--verbose"])
        .args(["--no-session-persistence", "--strict-mcp-config"])
        .args(["--disable-slash-commands", "--tools", ""])
        .args(["--model", model]);
    if !prompt.system.is_empty() {
        cmd.args(["--system-prompt", &prompt.system]);
    }
    for key in ENV_PASSTHROUGH {
        if let Ok(value) = std::env::var(key) {
            cmd.env(key, value);
        }
    }
    let mut child = cmd
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .map_err(|e| format!("failed to spawn claude CLI: {e}"))?;
    child
        .stdin
        .take()
        .ok_or("claude CLI stdin unavailable")?
        .write_all(prompt.prompt.as_bytes())
        .map_err(|e| e.to_string())?;
    let out = child.wait_with_output().map_err(|e| e.to_string())?;
    Ok(String::from_utf8_lossy(&out.stdout).to_string())
}

fn handle(req: Request, stream: &mut std::net::TcpStream, cfg: &Config) {
    let authorized = req
        .header("authorization")
        .and_then(|h| h.strip_prefix("Bearer "))
        .is_some_and(|token| token == cfg.claude_cli_token);
    if !authorized {
        let _ = http::send_json(stream, 401, "{\"error\":{\"message\":\"unauthorized\"}}");
        return;
    }
    if req.method != "POST" || req.path != "/v1/chat/completions" {
        let _ = http::send_json(stream, 404, "{\"error\":{\"message\":\"not found\"}}");
        return;
    }
    let Ok(body) = json::parse(&String::from_utf8_lossy(&req.body)) else {
        let _ = http::send_json(stream, 400, "{\"error\":{\"message\":\"invalid JSON\"}}");
        return;
    };
    let model = body
        .get_str("model")
        .unwrap_or(&cfg.default_model)
        .to_string();
    let prompt = build_prompt(&body);
    let result =
        run_cli(cfg, &model, &prompt).and_then(|output| stream_json_to_completion(&model, &output));
    match result {
        Ok(completion) => {
            let _ = http::send_json(stream, 200, &completion);
        }
        Err(message) => {
            let mut err = Json::object();
            err.set("message", Json::String(message));
            let mut root = Json::object();
            root.set("error", err);
            let _ = http::send_json(stream, 502, &root.stringify());
        }
    }
}

pub fn serve_runner(cfg: Config) -> Result<(), String> {
    if cfg.claude_cli_token.len() < 24 {
        return Err("AKURAI_ROUTER_CLAUDE_CLI_TOKEN must be at least 24 characters".to_string());
    }
    let addr = cfg.claude_runner_addr.clone();
    http::serve(&addr, move |req, stream| handle(req, stream, &cfg))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn single_user_message_is_passed_verbatim() {
        let body = json::parse(r#"{"messages":[{"role":"user","content":"hello"}]}"#).unwrap();
        let p = build_prompt(&body);
        assert_eq!(p.prompt, "hello");
        assert_eq!(p.system, "");
    }

    #[test]
    fn system_messages_split_out_and_turns_labelled() {
        let body = json::parse(
            r#"{"messages":[{"role":"system","content":"be brief"},{"role":"user","content":"a"},{"role":"assistant","content":"b"},{"role":"user","content":[{"type":"text","text":"c"}]}]}"#,
        )
        .unwrap();
        let p = build_prompt(&body);
        assert_eq!(p.system, "be brief");
        assert_eq!(p.prompt, "User: a\n\nAssistant: b\n\nUser: c");
    }

    #[test]
    fn result_event_maps_to_chat_completion() {
        let out = "{\"type\":\"system\"}\n{\"type\":\"result\",\"is_error\":false,\"result\":\"Hi!\",\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":8000,\"output_tokens\":12}}\n";
        let parsed =
            json::parse(&stream_json_to_completion("claude-haiku-4-5", out).unwrap()).unwrap();
        let choice = match parsed.get("choices") {
            Some(Json::Array(c)) => c[0].clone(),
            _ => panic!("no choices"),
        };
        assert_eq!(
            choice.get("message").unwrap().get_str("content"),
            Some("Hi!")
        );
        assert_eq!(choice.get_str("finish_reason"), Some("stop"));
        assert_eq!(parsed.get_str("model"), Some("claude-haiku-4-5"));
        assert_eq!(
            parsed.get("usage").unwrap().get("total_tokens"),
            Some(&Json::Number("8021".to_string()))
        );
    }

    #[test]
    fn cli_errors_surface_as_errors() {
        let out = "{\"type\":\"result\",\"is_error\":true,\"result\":\"Not logged in\"}";
        assert!(
            stream_json_to_completion("m", out)
                .unwrap_err()
                .contains("Not logged in")
        );
        assert!(stream_json_to_completion("m", "garbage").is_err());
    }
}