use anyhow::{anyhow, Context, Result};
use serde_json::Value as Json;
use std::time::Duration;
pub fn gate_url() -> String {
std::env::var("IRONGATE_URL").unwrap_or_else(|_| "http://localhost:7700/v1".to_string())
}
pub fn gate_root() -> String {
root_of(&gate_url())
}
pub fn gate_model() -> String {
std::env::var("IRONGATE_MODEL").unwrap_or_else(|_| "auto".to_string())
}
pub fn gate_token() -> Option<String> {
std::env::var("IRONGATE_API_KEY")
.or_else(|_| std::env::var("IRONGATE_TOKEN"))
.ok()
.filter(|s| !s.is_empty())
}
#[derive(Debug, Clone)]
pub struct GateHealth {
pub providers: u64,
pub models: Vec<String>,
}
fn probe_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.timeout(timeout)
.build()
.context("building HTTP client")
}
pub fn gate_health() -> Result<GateHealth> {
let url = format!("{}/health", gate_root());
let client = probe_client(Duration::from_secs(2))?;
let resp = client
.get(&url)
.send()
.with_context(|| format!("IronGate not reachable at {url}"))?
.error_for_status()?;
let body: Json = resp.json().context("IronGate /health returned non-JSON")?;
Ok(GateHealth {
providers: body["providers"].as_u64().unwrap_or(0),
models: body["models"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|m| m.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
})
}
pub fn gate_status() -> Result<Json> {
let url = format!("{}/status", gate_root());
let client = probe_client(Duration::from_secs(2))?;
let resp = client
.get(&url)
.send()
.with_context(|| format!("IronGate not reachable at {url}"))?
.error_for_status()?;
resp.json().context("IronGate /status returned non-JSON")
}
pub fn gate_available() -> bool {
gate_health().is_ok()
}
#[derive(Debug, Clone)]
pub struct GateCompletion {
pub text: String,
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub target: Option<String>,
pub difficulty: Option<String>,
pub elapsed_ms: f64,
}
pub fn gate_complete(model: &str, prompt: &str, max_tokens: Option<u32>) -> Result<GateCompletion> {
let base = gate_url();
let url = format!("{}/chat/completions", base.trim_end_matches('/'));
let mut body = serde_json::json!({
"model": model,
"messages": [{ "role": "user", "content": prompt }],
});
if let Some(n) = max_tokens {
body["max_tokens"] = serde_json::json!(n);
}
let client = crate::security::create_secure_http_client()
.context("Failed to create secure HTTP client")?;
let mut req = client.post(&url).json(&body);
if let Some(token) = gate_token() {
req = req.header("Authorization", format!("Bearer {token}"));
}
let started = std::time::Instant::now();
let resp = req.send().with_context(|| {
format!("IronGate not reachable at {base}. Start it, or set IRONGATE_URL.")
})?;
let header = |name: &str| {
resp.headers()
.get(name)
.and_then(|h| h.to_str().ok())
.map(String::from)
};
let target = header("x-irongate-target");
let difficulty = header("x-irongate-difficulty");
let resp = resp.error_for_status()?;
let v: Json = resp.json().context("IronGate returned non-JSON")?;
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
let text = v["choices"][0]["message"]["content"]
.as_str()
.ok_or_else(|| anyhow!("IronGate response missing content field"))?
.to_string();
Ok(GateCompletion {
text,
prompt_tokens: v["usage"]["prompt_tokens"].as_u64().unwrap_or(0) as u32,
completion_tokens: v["usage"]["completion_tokens"].as_u64().unwrap_or(0) as u32,
target,
difficulty,
elapsed_ms,
})
}
pub fn vault_bin() -> String {
std::env::var("IRONVAULT_BIN").unwrap_or_else(|_| "iv".to_string())
}
pub fn vault_run(args: &[&str]) -> Result<String> {
let bin = vault_bin();
let out = std::process::Command::new(&bin)
.args(args)
.output()
.map_err(|e| {
anyhow!(
"IronVault CLI '{bin}' could not be run: {e}. \
Install it with `cargo install ironvault`, or set IRONVAULT_BIN \
to its path."
)
})?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(anyhow!(
"`{bin} {}` failed: {}",
args.join(" "),
if stderr.trim().is_empty() {
"no error output".to_string()
} else {
stderr.trim().to_string()
}
));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
pub fn vault_available() -> bool {
std::process::Command::new(vault_bin())
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn vault_list() -> Result<Json> {
let stdout = vault_run(&["list", "--format", "json"])?;
serde_json::from_str(&stdout).context("`iv list --format json` returned non-JSON")
}
pub fn vault_conversions() -> Result<Json> {
let stdout = vault_run(&["list-conversions", "--format", "json"])
.or_else(|_| vault_run(&["list-conversions"]))?;
serde_json::from_str(&stdout).or_else(|_| Ok(Json::String(stdout.trim().to_string())))
}
pub fn vault_convert(
name: &str,
to_format: &str,
quantization: Option<&str>,
output: Option<&str>,
validate: bool,
) -> Result<String> {
let mut args: Vec<String> = vec![
"convert".to_string(),
name.to_string(),
"--to-format".to_string(),
to_format.to_string(),
];
if let Some(q) = quantization {
args.push("--quantization".to_string());
args.push(q.to_string());
}
if let Some(o) = output {
args.push("--output".to_string());
args.push(o.to_string());
}
if validate {
args.push("--validate".to_string());
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
vault_run(&refs)
}
fn root_of(url: &str) -> String {
let trimmed = url.trim_end_matches('/');
trimmed
.strip_suffix("/v1")
.unwrap_or(trimmed)
.trim_end_matches('/')
.to_string()
}
#[cfg(test)]
mod tests {
#[test]
fn the_root_is_the_v1_url_without_its_suffix() {
assert_eq!(
super::root_of("http://localhost:7700/v1"),
"http://localhost:7700"
);
}
#[test]
fn a_url_already_at_the_root_is_left_alone() {
assert_eq!(
super::root_of("http://gate.internal"),
"http://gate.internal"
);
}
#[test]
fn a_trailing_slash_does_not_produce_a_double_slash() {
assert_eq!(
super::root_of("http://localhost:7700/v1/"),
"http://localhost:7700"
);
}
#[test]
fn a_path_that_merely_contains_v1_is_not_truncated() {
assert_eq!(
super::root_of("http://host/v1/gateway"),
"http://host/v1/gateway"
);
}
}