use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{Context, Result};
use rig::completion::CompletionRequest;
use rig::providers::openai;
use rig::schemars::{self, JsonSchema};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use crate::prompts::{Prompts, Task};
pub const DEFAULT_MODEL: &str = "gpt-6-luna";
pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
pub struct LlmClient {
client: openai::OpenAI,
model: String,
models: std::collections::BTreeMap<Task, String>,
openai_native: bool,
input_tokens: Arc<AtomicU64>,
output_tokens: Arc<AtomicU64>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[schemars(crate = "rig::schemars")]
pub struct Enrichment {
pub summary: String,
pub queries: Vec<String>,
}
impl LlmClient {
pub fn new(base_url: Option<&str>, model: &str, key: Option<&str>) -> Result<Self> {
let key = key.map(str::to_string);
let client = match base_url {
Some(base) => openai::OpenAIConfig::new(key.unwrap_or_else(|| "sk-noauth".into()))
.with_base_url(base)
.client(),
None => openai::OpenAIConfig::new(key.context("no LLM key")?).client(),
};
Ok(Self {
client,
model: model.to_string(),
models: Default::default(),
openai_native: base_url.is_none(),
input_tokens: Arc::new(AtomicU64::new(0)),
output_tokens: Arc::new(AtomicU64::new(0)),
})
}
pub fn token_usage(&self) -> (u64, u64) {
(
self.input_tokens.load(Ordering::Relaxed),
self.output_tokens.load(Ordering::Relaxed),
)
}
pub fn with_models(mut self, models: std::collections::BTreeMap<Task, String>) -> Self {
self.models = models;
self
}
pub fn model(&self, task: Task) -> &str {
self.models.get(&task).unwrap_or(&self.model)
}
async fn complete<T: DeserializeOwned + JsonSchema>(
&self,
task: Task,
system: &str,
user: &str,
max_tokens: u32,
) -> Result<T> {
let req = CompletionRequest::new(user.to_string())
.preamble(system)
.output_schema(schemars::schema_for!(T));
let req = if self.openai_native {
req.additional_params(serde_json::json!({
"max_completion_tokens": (max_tokens as u64 * 4).max(4000)
}))
} else {
req.max_tokens(max_tokens as u64)
};
let resp = self
.client
.chat(self.model(task))
.call(req)
.await
.context("LLM completion")?;
self.input_tokens
.fetch_add(resp.usage.input_tokens.unwrap_or(0), Ordering::Relaxed);
self.output_tokens
.fetch_add(resp.usage.output_tokens.unwrap_or(0), Ordering::Relaxed);
parse(&resp.text())
}
pub async fn enrich_symbol(
&self,
prompts: &Prompts,
name: &str,
signature: &str,
context: &str,
) -> Result<Enrichment> {
let user = format!("Symbol: {name}\nSignature: {signature}\nContext: {context}");
let r: Enrichment = self
.complete(Task::Summary, &prompts.system(Task::Summary), &user, 160)
.await?;
Ok(Enrichment {
summary: r.summary.trim().to_string(),
queries: tidy(r.queries),
})
}
pub async fn guide_section(
&self,
prompts: &Prompts,
repo: &str,
title: &str,
guidance: &str,
evidence: &str,
) -> Result<Option<String>> {
let user = format!(
"Repository: {repo}\nSection: {title}\nWhat to cover: {guidance}\n\n# Evidence\n\n{evidence}"
);
let r: GuideSection = self
.complete(Task::Guide, &prompts.system(Task::Guide), &user, 900)
.await?;
let rules = tidy(r.rules);
Ok((r.observed && !rules.is_empty()).then(|| {
rules
.iter()
.map(|r| format!("- {}", r.trim_start_matches("- ")))
.collect::<Vec<_>>()
.join("\n")
}))
}
pub async fn narrate_flow(
&self,
prompts: &Prompts,
entry: &str,
signature: &str,
trace: &str,
) -> Result<(String, Vec<String>)> {
let user = format!("Entry point: {entry}\nSignature: {signature}\nCall trace:\n{trace}");
let r: Walkthrough = self
.complete(Task::Flow, &prompts.system(Task::Flow), &user, 320)
.await?;
Ok((r.narration.trim().to_string(), tidy(r.queries)))
}
}
#[derive(Deserialize, JsonSchema)]
#[schemars(crate = "rig::schemars")]
struct GuideSection {
observed: bool,
rules: Vec<String>,
}
#[derive(Deserialize, JsonSchema)]
#[schemars(crate = "rig::schemars")]
struct Walkthrough {
narration: String,
queries: Vec<String>,
}
pub fn reply_schema(task: Task) -> String {
let schema = match task {
Task::Summary => schemars::schema_for!(Enrichment),
Task::Guide => schemars::schema_for!(GuideSection),
Task::Flow => schemars::schema_for!(Walkthrough),
};
serde_json::to_string_pretty(&schema).unwrap_or_default()
}
fn tidy(items: Vec<String>) -> Vec<String> {
items
.into_iter()
.map(|q| q.trim().to_string())
.filter(|q| !q.is_empty())
.collect()
}
fn parse<T: DeserializeOwned>(raw: &str) -> Result<T> {
let raw = raw.trim();
if let Ok(v) = serde_json::from_str(raw) {
return Ok(v);
}
let (Some(a), Some(b)) = (raw.find('{'), raw.rfind('}')) else {
anyhow::bail!("the LLM did not reply with JSON");
};
anyhow::ensure!(a < b, "the LLM did not reply with JSON");
serde_json::from_str(&raw[a..=b]).context("the LLM's JSON reply has the wrong shape")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_json_also_inside_prose() {
let e: Enrichment =
parse(r#"{"summary":"Opens a pooled connection.","queries":["how to connect"]}"#)
.unwrap();
assert_eq!(e.summary, "Opens a pooled connection.");
let fenced =
"Here it is:\n```json\n{\"narration\": \"Calls a, then b.\", \"queries\": []}\n```";
let w: Walkthrough = parse(fenced).unwrap();
assert_eq!(w.narration, "Calls a, then b.");
assert!(parse::<Enrichment>("SUMMARY: no json").is_err());
assert!(parse::<Enrichment>(r#"{"summary": 3}"#).is_err());
}
#[test]
fn the_schema_is_strict_enough_for_openai() {
let s = serde_json::to_value(schemars::schema_for!(GuideSection)).unwrap();
assert_eq!(s["required"], serde_json::json!(["observed", "rules"]));
}
#[tokio::test]
#[ignore]
async fn live_enrich_symbol() {
let key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY");
let c = LlmClient::new(None, DEFAULT_MODEL, Some(&key)).unwrap();
let e = c
.enrich_symbol(
&Prompts::default(),
"AsyncTaskRunner",
"class AsyncTaskRunner(BaseTaskRunner)",
"acme/database/executors.py",
)
.await
.expect("enrich");
assert!(!e.summary.is_empty());
eprintln!("summary: {}", e.summary);
eprintln!("queries: {:?}", e.queries);
}
#[tokio::test]
#[ignore]
async fn live_compatible_server() {
let base = std::env::var("COMINDLY_TEST_LLM_BASE_URL").expect("COMINDLY_TEST_LLM_BASE_URL");
let model = std::env::var("COMINDLY_TEST_LLM_MODEL").expect("COMINDLY_TEST_LLM_MODEL");
let c = LlmClient::new(Some(&base), &model, None).unwrap();
let p = Prompts::default();
let e = c
.enrich_symbol(
&p,
"open_pool",
"fn open_pool(cfg: &DbConfig) -> Pool",
"src/db/pool.rs",
)
.await
.expect("summary");
assert!(!e.summary.is_empty());
eprintln!("summary: {}\nqueries: {:?}", e.summary, e.queries);
let (n, _) = c
.narrate_flow(
&p,
"handle_order",
"fn handle_order(o: Order)",
"d1 calls acme:validate src/a.rs:3",
)
.await
.expect("flow");
eprintln!("flow: {n}");
}
#[tokio::test]
#[ignore]
async fn live_guide_and_flow() {
let key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY");
let c = LlmClient::new(None, DEFAULT_MODEL, Some(&key)).unwrap();
let p = Prompts::default();
let evidence = "snake_case functions 97% (1234/1268)\nsrc/db/pool.rs: pub fn open_pool(cfg: &DbConfig) -> Pool";
let g = c
.guide_section(&p, "acme", "Naming", "How things are named", evidence)
.await
.expect("guide");
eprintln!("guide: {g:?}");
let (n, q) = c
.narrate_flow(
&p,
"handle_order",
"fn handle_order(o: Order)",
"d1 calls acme:validate src/a.rs:3\nd2 calls acme:save src/b.rs:9",
)
.await
.expect("flow");
assert!(!n.is_empty());
eprintln!("flow: {n}\n{q:?}");
}
}