#![cfg(feature = "llm")]
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result, bail};
use async_trait::async_trait;
use tokio::io::AsyncWriteExt as _;
use crate::llm::{
CLASSIFIER_SYSTEM_PROMPT, HYDE_SYSTEM_PROMPT, LlmCapability, build_classify_prompt,
build_hyde_prompt, parse_route_from_llm_output,
};
use crate::search::agent_classifier::AgentRoute;
fn classify_timeout() -> Duration {
std::env::var("SEMANTEX_LLM_CLASSIFY_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map_or_else(|| Duration::from_secs(8), Duration::from_millis)
}
fn hyde_timeout() -> Duration {
std::env::var("SEMANTEX_LLM_HYDE_TIMEOUT_MS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.map_or_else(|| Duration::from_secs(15), Duration::from_millis)
}
const MAX_CLASSIFY_STDOUT_BYTES: usize = 1_024;
const MAX_HYDE_STDOUT_BYTES: usize = 50 * 1_024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CliKind {
Claude,
Codex,
}
#[derive(Debug)]
pub struct SubscriptionCliBackend {
kind: CliKind,
binary: PathBuf,
label: String,
}
impl SubscriptionCliBackend {
pub fn from_env() -> Result<Option<Self>> {
let Some(spec) = std::env::var("SEMANTEX_LLM_BACKEND").ok() else {
return Ok(None);
};
let Some(kind_str) = spec.strip_prefix("cli:") else {
return Ok(None);
};
let kind = match kind_str {
"claude" => CliKind::Claude,
"codex" => CliKind::Codex,
"antigravity" => bail!(
"cli:antigravity is not yet supported. Use cli:claude or cli:codex \
until the Antigravity headless surface stabilizes (Spec L §5 Item 2.4)."
),
"auto" => return Self::auto_detect().map(Some),
other => bail!("unknown SEMANTEX_LLM_BACKEND value: cli:{other}"),
};
let binary = Self::resolve_binary(kind)?;
Ok(Some(Self::new(kind, binary)))
}
fn auto_detect() -> Result<Self> {
for kind in [CliKind::Claude, CliKind::Codex] {
if let Ok(binary) = Self::resolve_binary(kind) {
return Ok(Self::new(kind, binary));
}
}
bail!("SEMANTEX_LLM_BACKEND=cli:auto but no supported CLI found on PATH")
}
fn resolve_binary(kind: CliKind) -> Result<PathBuf> {
let name = Self::cli_name(kind);
if let Ok(path) = std::env::var("SEMANTEX_LLM_CLI_BINARY") {
let p = PathBuf::from(&path);
let basename_matches = p
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|stem| stem == name);
if basename_matches {
if p.exists() {
return Ok(p);
}
bail!("SEMANTEX_LLM_CLI_BINARY={path} does not exist");
}
}
which::which(name).with_context(|| {
format!("{name} CLI not found on PATH — install it or set SEMANTEX_LLM_CLI_BINARY")
})
}
fn cli_name(kind: CliKind) -> &'static str {
match kind {
CliKind::Claude => "claude",
CliKind::Codex => "codex",
}
}
fn new(kind: CliKind, binary: PathBuf) -> Self {
let label = format!("cli:{}", Self::cli_name(kind));
Self {
kind,
binary,
label,
}
}
async fn exec(
&self,
prompt: &str,
timeout: Duration,
max_stdout_bytes: usize,
) -> Result<String> {
let mut cmd = tokio::process::Command::new(&self.binary);
match self.kind {
CliKind::Claude => {
cmd.args(["--print", "--output-format", "json"]);
}
CliKind::Codex => {
cmd.args(["exec", "--quiet"]);
}
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().context("spawn LLM CLI")?;
let mut stdin = child
.stdin
.take()
.context("child stdin not available (piped)")?;
stdin
.write_all(prompt.as_bytes())
.await
.context("write prompt to LLM CLI stdin")?;
drop(stdin);
let output = tokio::time::timeout(timeout, child.wait_with_output())
.await
.context("LLM CLI timed out")?
.context("LLM CLI wait_with_output")?;
if !output.status.success() {
bail!(
"LLM CLI exited {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
let n = output.stdout.len();
if n > max_stdout_bytes {
bail!(
"LLM CLI produced oversized output ({n} bytes); expected at most \
{max_stdout_bytes} bytes"
);
}
let raw = String::from_utf8_lossy(&output.stdout).into_owned();
Ok(self.kind.extract_text(&raw))
}
}
impl CliKind {
pub(crate) fn extract_text(self, raw: &str) -> String {
match self {
CliKind::Claude => serde_json::from_str::<ClaudeJsonResult>(raw).map_or_else(
|_| raw.to_string(),
|r| {
if r.is_error {
format!("[LLM ERROR] {}", r.result)
} else {
r.result
}
},
),
CliKind::Codex => raw.to_string(),
}
}
}
#[derive(serde::Deserialize)]
struct ClaudeJsonResult {
result: String,
#[serde(default)]
is_error: bool,
}
#[async_trait]
impl LlmCapability for SubscriptionCliBackend {
async fn classify_route(&self, query: &str) -> Result<AgentRoute> {
let prompt = format!(
"{CLASSIFIER_SYSTEM_PROMPT}\n\n{}",
build_classify_prompt(query)
);
let stdout = self
.exec(&prompt, classify_timeout(), MAX_CLASSIFY_STDOUT_BYTES)
.await?;
parse_route_from_llm_output(&stdout)
}
async fn synthesize_hyde_doc(&self, query: &str) -> Result<String> {
let prompt = format!("{HYDE_SYSTEM_PROMPT}\n\n{}", build_hyde_prompt(query));
self.exec(&prompt, hyde_timeout(), MAX_HYDE_STDOUT_BYTES)
.await
}
fn label(&self) -> &str {
&self.label
}
}
#[cfg(all(feature = "llm", test))]
mod tests {
use super::*;
use crate::llm::TEST_ENV_LOCK as ENV_LOCK;
fn set_env(key: &str, value: &str) {
unsafe { std::env::set_var(key, value) };
}
fn unset_env(key: &str) {
unsafe { std::env::remove_var(key) };
}
#[test]
fn from_env_returns_none_when_unset() {
let _guard = ENV_LOCK.lock().unwrap();
unset_env("SEMANTEX_LLM_BACKEND");
unset_env("SEMANTEX_LLM_CLI_BINARY");
let result = SubscriptionCliBackend::from_env();
assert!(
matches!(result, Ok(None)),
"expected Ok(None), got {result:?}"
);
}
#[test]
fn from_env_returns_none_when_not_cli_prefix() {
let _guard = ENV_LOCK.lock().unwrap();
set_env("SEMANTEX_LLM_BACKEND", "genai/something");
unset_env("SEMANTEX_LLM_CLI_BINARY");
let result = SubscriptionCliBackend::from_env();
unset_env("SEMANTEX_LLM_BACKEND");
assert!(
matches!(result, Ok(None)),
"expected Ok(None), got {result:?}"
);
}
#[test]
fn from_env_rejects_unknown_kind() {
let _guard = ENV_LOCK.lock().unwrap();
set_env("SEMANTEX_LLM_BACKEND", "cli:bogus");
unset_env("SEMANTEX_LLM_CLI_BINARY");
let result = SubscriptionCliBackend::from_env();
unset_env("SEMANTEX_LLM_BACKEND");
let err = result.unwrap_err();
assert!(
err.to_string()
.contains("unknown SEMANTEX_LLM_BACKEND value: cli:bogus"),
"unexpected error: {err}"
);
}
#[test]
fn from_env_rejects_antigravity_at_startup() {
let _guard = ENV_LOCK.lock().unwrap();
set_env("SEMANTEX_LLM_BACKEND", "cli:antigravity");
unset_env("SEMANTEX_LLM_CLI_BINARY");
let result = SubscriptionCliBackend::from_env();
unset_env("SEMANTEX_LLM_BACKEND");
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("antigravity") && msg.contains("Use cli:claude"),
"expected antigravity startup-error message, got: {msg}"
);
}
#[test]
fn extract_text_claude_strips_json() {
let raw = r#"{"type":"result","result":"deep","is_error":false}"#;
assert_eq!(CliKind::Claude.extract_text(raw), "deep");
}
#[test]
fn extract_text_claude_malformed_falls_back_to_raw() {
let raw = "not json";
assert_eq!(CliKind::Claude.extract_text(raw), "not json");
}
#[test]
fn extract_text_codex_passes_through() {
let raw = "deep";
assert_eq!(CliKind::Codex.extract_text(raw), "deep");
}
#[test]
fn classify_timeout_env_override() {
let _guard = ENV_LOCK.lock().unwrap();
set_env("SEMANTEX_LLM_CLASSIFY_TIMEOUT_MS", "3000");
let t = classify_timeout();
unset_env("SEMANTEX_LLM_CLASSIFY_TIMEOUT_MS");
assert_eq!(t, Duration::from_millis(3000));
}
#[test]
fn hyde_timeout_env_override() {
let _guard = ENV_LOCK.lock().unwrap();
set_env("SEMANTEX_LLM_HYDE_TIMEOUT_MS", "30000");
let t = hyde_timeout();
unset_env("SEMANTEX_LLM_HYDE_TIMEOUT_MS");
assert_eq!(t, Duration::from_millis(30000));
}
#[test]
fn default_timeouts_are_correct() {
let _guard = ENV_LOCK.lock().unwrap();
unset_env("SEMANTEX_LLM_CLASSIFY_TIMEOUT_MS");
unset_env("SEMANTEX_LLM_HYDE_TIMEOUT_MS");
assert_eq!(classify_timeout(), Duration::from_secs(8));
assert_eq!(hyde_timeout(), Duration::from_secs(15));
}
#[test]
fn exec_claude_command_has_no_prompt_arg() {
let args: &[&str] = &["--print", "--output-format", "json"];
assert_eq!(
args.len(),
3,
"Claude argv must have exactly 3 elements (no prompt positional): {args:?}"
);
assert_eq!(args[0], "--print");
assert_eq!(args[1], "--output-format");
assert_eq!(args[2], "json");
}
#[tokio::test]
#[ignore = "requires claude on PATH and SEMANTEX_LLM_TEST_CLI=claude"]
async fn real_claude_classify() {
if std::env::var("SEMANTEX_LLM_TEST_CLI")
.map(|v| v == "claude")
.unwrap_or(false)
{
set_env("SEMANTEX_LLM_BACKEND", "cli:claude");
unset_env("SEMANTEX_LLM_CLI_BINARY");
let backend = SubscriptionCliBackend::from_env()
.expect("from_env failed")
.expect("expected Some backend");
let route = backend
.classify_route("who calls handle_request?")
.await
.expect("classify_route failed");
assert_eq!(route, AgentRoute::Structural);
}
}
#[tokio::test]
#[ignore = "requires codex on PATH and SEMANTEX_LLM_TEST_CLI=codex"]
async fn real_codex_classify() {
if std::env::var("SEMANTEX_LLM_TEST_CLI")
.map(|v| v == "codex")
.unwrap_or(false)
{
set_env("SEMANTEX_LLM_BACKEND", "cli:codex");
unset_env("SEMANTEX_LLM_CLI_BINARY");
let backend = SubscriptionCliBackend::from_env()
.expect("from_env failed")
.expect("expected Some backend");
let route = backend
.classify_route("who calls handle_request?")
.await
.expect("classify_route failed");
assert_eq!(route, AgentRoute::Structural);
}
}
}