use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use anyhow::{Context, Result};
use futures::stream::{self, StreamExt};
use rig::completion::CompletionRequest;
use rig::providers::openai;
pub const DEFAULT_MODEL: &str = "gpt-6-luna";
pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
const CONCURRENCY: usize = 8;
pub struct LlmClient {
client: openai::OpenAI,
model: String,
openai_native: bool,
input_tokens: Arc<AtomicU64>,
output_tokens: Arc<AtomicU64>,
}
#[derive(Debug, Clone)]
pub struct Enrichment {
pub summary: String,
pub queries: Vec<String>,
}
impl LlmClient {
pub fn from_env() -> Result<Self> {
let team = crate::team::current().and_then(|t| t.name);
if let (Some(k), _) = crate::credentials::llm_key(team.as_deref()) {
if std::env::var("OPENAI_API_KEY").ok().as_deref() != Some(k.as_str()) {
std::env::set_var("OPENAI_API_KEY", k);
}
}
let cfg = crate::config::Config::load();
let model = cfg.llm_model();
let openai_native = cfg.llm_base_url().is_none()
&& std::env::var("OPENAI_BASE_URL").map_or(true, |u| u.contains("api.openai.com"));
let client = match cfg.llm_base_url() {
Some(base) => {
let key =
std::env::var("OPENAI_API_KEY").unwrap_or_else(|_| "sk-noauth".to_string());
openai::OpenAIConfig::new(key).with_base_url(base).client()
}
None => openai::OpenAI::from_env().map_err(|e| {
anyhow::anyhow!(
"OpenAI client (set OPENAI_API_KEY, or COMINDLY_LLM_BASE_URL for an OpenAI-compatible endpoint): {e}"
)
})?,
};
Ok(Self {
client,
model,
openai_native,
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),
)
}
async fn complete(&self, system: &str, user: &str, max_tokens: u32) -> Result<String> {
let req = CompletionRequest::new(user.to_string()).preamble(system);
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)
.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);
Ok(resp.text().trim().to_string())
}
pub async fn enrich_symbol(
&self,
name: &str,
signature: &str,
context: &str,
) -> Result<Enrichment> {
let system = "You document code for a search index. Reply in exactly this format:\n\
SUMMARY: <one concise sentence describing what this does>\n\
QUERIES: <3 natural-language questions a developer might ask that this code answers, separated by ` | `>\n\
No preamble, no code fences.";
let user = format!("Symbol: {name}\nSignature: {signature}\nContext: {context}");
let raw = self.complete(system, &user, 160).await?;
Ok(parse_enrichment(&raw))
}
pub async fn enrich_batch(
&self,
items: &[(String, String, String)],
) -> Vec<Option<Enrichment>> {
stream::iter(items.iter())
.map(|(n, s, c)| async move { self.enrich_symbol(n, s, c).await.ok() })
.buffered(CONCURRENCY)
.collect()
.await
}
pub async fn guide_section(
&self,
repo: &str,
title: &str,
guidance: &str,
evidence: &str,
) -> Result<String> {
let system = "You are a senior engineer documenting ONE section of a codebase's house-style \
guide so an AI coding agent writes code INDISTINGUISHABLE from this repo's. You are given \
REAL evidence (signatures, code excerpts, dependency facts, measured stats) for this \
section only. Make the repo's tacit conventions explicit.\n\
Hard rules:\n\
- Output ONLY this section's rules as terse markdown bullets — NO top-level heading, no \
preamble, no closing note.\n\
- Cite a real FILE PATH (and symbol/signature) for every non-trivial claim; quote a short \
real snippet as the example. Where useful, show a correct vs incorrect example.\n\
- Prefer the repo's OWN wrapper/abstraction over a raw library when the evidence shows \
one; name it exactly.\n\
- NEVER output generic advice that would apply to any codebase ('use meaningful names', \
'write tests', 'handle errors gracefully'). Delete such lines.\n\
- Strength tags: **MUST**/**NEVER** for patterns across ≥3 files or enforced by config; \
'prefer' for weaker signals. Give a one-line reason where non-obvious.\n\
- If the evidence shows no consistent convention, reply with exactly: \
No consistent convention observed.";
let user = format!(
"Repository: {repo}\nSection: {title}\nWhat to cover: {guidance}\n\n# Evidence\n\n{evidence}"
);
self.complete(system, &user, 900).await
}
pub async fn narrate_flow(
&self,
entry: &str,
signature: &str,
trace: &str,
) -> Result<(String, Vec<String>)> {
let system = "You explain how a code flow works to a developer new to the codebase. \
Given an entry point and its call trace (each line: depth, relation, symbol, \
location), reply in exactly this format:\n\
NARRATION: <2-5 sentence walkthrough of what happens step by step; reference the \
actual symbol names>\n\
QUERIES: <3 questions a developer might ask about this flow, separated by ` | `>\n\
No preamble, no code fences.";
let user = format!("Entry point: {entry}\nSignature: {signature}\nCall trace:\n{trace}");
let raw = self.complete(system, &user, 320).await?;
Ok(parse_flow(&raw))
}
}
fn parse_flow(raw: &str) -> (String, Vec<String>) {
let (narr_part, q_part) = raw.split_once("QUERIES:").unwrap_or((raw, ""));
let narration = narr_part
.trim()
.trim_start_matches("NARRATION:")
.trim()
.to_string();
let queries = q_part
.split('|')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
(
if narration.is_empty() {
raw.trim().to_string()
} else {
narration
},
queries,
)
}
fn parse_enrichment(raw: &str) -> Enrichment {
let mut summary = String::new();
let mut queries = Vec::new();
for line in raw.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("SUMMARY:") {
summary = rest.trim().to_string();
} else if let Some(rest) = l.strip_prefix("QUERIES:") {
queries = rest
.split('|')
.map(|q| q.trim().to_string())
.filter(|q| !q.is_empty())
.collect();
}
}
if summary.is_empty() {
summary = raw
.lines()
.map(str::trim)
.find(|l| !l.is_empty())
.unwrap_or("")
.to_string();
}
Enrichment { summary, queries }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_enrichment_format() {
let raw = "SUMMARY: Opens a pooled Postgres connection.\nQUERIES: how to connect to postgres | get a db connection | open database pool";
let e = parse_enrichment(raw);
assert_eq!(e.summary, "Opens a pooled Postgres connection.");
assert_eq!(e.queries.len(), 3);
assert_eq!(e.queries[0], "how to connect to postgres");
}
#[tokio::test]
#[ignore]
async fn live_enrich_symbol() {
let c = LlmClient::from_env().expect("OPENAI_API_KEY");
let e = c
.enrich_symbol(
"AsyncTaskRunner",
"class AsyncTaskRunner(BaseTaskRunner)",
"acme/database/executors.py",
)
.await
.expect("enrich");
assert!(!e.summary.is_empty());
eprintln!("summary: {}", e.summary);
eprintln!("queries: {:?}", e.queries);
}
}