1use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::Arc;
12
13use anyhow::{Context, Result};
14use rig::completion::CompletionRequest;
15use rig::providers::openai;
16use rig::schemars::{self, JsonSchema};
17use serde::de::DeserializeOwned;
18use serde::Deserialize;
19
20use crate::prompts::{Prompts, Task};
21
22pub const DEFAULT_MODEL: &str = "gpt-6-luna";
24
25pub const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
27
28pub struct LlmClient {
29 client: openai::OpenAI,
32 model: String,
33 models: std::collections::BTreeMap<Task, String>,
35 openai_native: bool,
38 input_tokens: Arc<AtomicU64>,
40 output_tokens: Arc<AtomicU64>,
41}
42
43#[derive(Debug, Clone, Deserialize, JsonSchema)]
45#[schemars(crate = "rig::schemars")]
46pub struct Enrichment {
47 pub summary: String,
49 pub queries: Vec<String>,
51}
52
53impl LlmClient {
54 pub fn new(base_url: Option<&str>, model: &str, key: Option<&str>) -> Result<Self> {
56 let key = key.map(str::to_string);
57 let client = match base_url {
58 Some(base) => openai::OpenAIConfig::new(key.unwrap_or_else(|| "sk-noauth".into()))
59 .with_base_url(base)
60 .client(),
61 None => openai::OpenAIConfig::new(key.context("no LLM key")?).client(),
62 };
63 Ok(Self {
64 client,
65 model: model.to_string(),
66 models: Default::default(),
67 openai_native: base_url.is_none(),
68 input_tokens: Arc::new(AtomicU64::new(0)),
69 output_tokens: Arc::new(AtomicU64::new(0)),
70 })
71 }
72
73 pub fn token_usage(&self) -> (u64, u64) {
75 (
76 self.input_tokens.load(Ordering::Relaxed),
77 self.output_tokens.load(Ordering::Relaxed),
78 )
79 }
80
81 pub fn with_models(mut self, models: std::collections::BTreeMap<Task, String>) -> Self {
83 self.models = models;
84 self
85 }
86
87 pub fn model(&self, task: Task) -> &str {
89 self.models.get(&task).unwrap_or(&self.model)
90 }
91
92 async fn complete<T: DeserializeOwned + JsonSchema>(
94 &self,
95 task: Task,
96 system: &str,
97 user: &str,
98 max_tokens: u32,
99 ) -> Result<T> {
100 let req = CompletionRequest::new(user.to_string())
102 .preamble(system)
103 .output_schema(schemars::schema_for!(T));
104 let req = if self.openai_native {
106 req.additional_params(serde_json::json!({
107 "max_completion_tokens": (max_tokens as u64 * 4).max(4000)
108 }))
109 } else {
110 req.max_tokens(max_tokens as u64)
111 };
112 let resp = self
113 .client
114 .chat(self.model(task))
115 .call(req)
116 .await
117 .context("LLM completion")?;
118 self.input_tokens
119 .fetch_add(resp.usage.input_tokens.unwrap_or(0), Ordering::Relaxed);
120 self.output_tokens
121 .fetch_add(resp.usage.output_tokens.unwrap_or(0), Ordering::Relaxed);
122 parse(&resp.text())
123 }
124
125 pub async fn enrich_symbol(&self, prompts: &Prompts, brief: &str) -> Result<Enrichment> {
127 let r: Enrichment = self
128 .complete(Task::Summary, &prompts.system(Task::Summary), brief, 400)
129 .await?;
130 Ok(Enrichment {
131 summary: r.summary.trim().to_string(),
132 queries: tidy(r.queries),
133 })
134 }
135
136 pub async fn guide_section(
139 &self,
140 prompts: &Prompts,
141 repo: &str,
142 title: &str,
143 guidance: &str,
144 evidence: &str,
145 ) -> Result<Option<String>> {
146 let user = format!(
147 "Repository: {repo}\nSection: {title}\nWhat to cover: {guidance}\n\n# Evidence\n\n{evidence}"
148 );
149 let r: GuideSection = self
150 .complete(Task::Guide, &prompts.system(Task::Guide), &user, 900)
151 .await?;
152 let rules = tidy(r.rules);
153 Ok((r.observed && !rules.is_empty()).then(|| {
154 rules
155 .iter()
156 .map(|r| format!("- {}", r.trim_start_matches("- ")))
157 .collect::<Vec<_>>()
158 .join("\n")
159 }))
160 }
161
162 pub async fn narrate_flow(
165 &self,
166 prompts: &Prompts,
167 entry: &str,
168 signature: &str,
169 trace: &str,
170 ) -> Result<(String, Vec<String>)> {
171 let user = format!("Entry point: {entry}\nSignature: {signature}\nCall trace:\n{trace}");
172 let r: Walkthrough = self
173 .complete(Task::Flow, &prompts.system(Task::Flow), &user, 700)
174 .await?;
175 Ok((r.narration.trim().to_string(), tidy(r.queries)))
176 }
177}
178
179#[derive(Deserialize, JsonSchema)]
181#[schemars(crate = "rig::schemars")]
182struct GuideSection {
183 observed: bool,
185 rules: Vec<String>,
187}
188
189#[derive(Deserialize, JsonSchema)]
191#[schemars(crate = "rig::schemars")]
192struct Walkthrough {
193 narration: String,
195 queries: Vec<String>,
197}
198
199pub fn reply_schema(task: Task) -> String {
201 let schema = match task {
202 Task::Summary => schemars::schema_for!(Enrichment),
203 Task::Guide => schemars::schema_for!(GuideSection),
204 Task::Flow => schemars::schema_for!(Walkthrough),
205 };
206 serde_json::to_string_pretty(&schema).unwrap_or_default()
207}
208
209fn tidy(items: Vec<String>) -> Vec<String> {
210 items
211 .into_iter()
212 .map(|q| q.trim().to_string())
213 .filter(|q| !q.is_empty())
214 .collect()
215}
216
217fn parse<T: DeserializeOwned>(raw: &str) -> Result<T> {
219 let raw = raw.trim();
220 if let Ok(v) = serde_json::from_str(raw) {
221 return Ok(v);
222 }
223 let (Some(a), Some(b)) = (raw.find('{'), raw.rfind('}')) else {
224 anyhow::bail!("the LLM did not reply with JSON");
225 };
226 anyhow::ensure!(a < b, "the LLM did not reply with JSON");
227 serde_json::from_str(&raw[a..=b]).context("the LLM's JSON reply has the wrong shape")
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn reads_json_also_inside_prose() {
236 let e: Enrichment =
237 parse(r#"{"summary":"Opens a pooled connection.","queries":["how to connect"]}"#)
238 .unwrap();
239 assert_eq!(e.summary, "Opens a pooled connection.");
240 let fenced =
241 "Here it is:\n```json\n{\"narration\": \"Calls a, then b.\", \"queries\": []}\n```";
242 let w: Walkthrough = parse(fenced).unwrap();
243 assert_eq!(w.narration, "Calls a, then b.");
244 assert!(parse::<Enrichment>("SUMMARY: no json").is_err());
245 assert!(parse::<Enrichment>(r#"{"summary": 3}"#).is_err());
246 }
247
248 #[test]
249 fn the_schema_is_strict_enough_for_openai() {
250 let s = serde_json::to_value(schemars::schema_for!(GuideSection)).unwrap();
251 assert_eq!(s["required"], serde_json::json!(["observed", "rules"]));
252 }
253
254 #[tokio::test]
256 #[ignore]
257 async fn live_enrich_symbol() {
258 let key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY");
259 let c = LlmClient::new(None, DEFAULT_MODEL, Some(&key)).unwrap();
260 let e = c
261 .enrich_symbol(
262 &Prompts::default(),
263 "Symbol: AsyncTaskRunner\nKind: Class in repository acme, acme/database/executors.py:12\nSignature: class AsyncTaskRunner(BaseTaskRunner)",
264 )
265 .await
266 .expect("enrich");
267 assert!(!e.summary.is_empty());
268 eprintln!("summary: {}", e.summary);
269 eprintln!("queries: {:?}", e.queries);
270 }
271
272 #[tokio::test]
274 #[ignore]
275 async fn live_compatible_server() {
276 let base = std::env::var("COMINDLY_TEST_LLM_BASE_URL").expect("COMINDLY_TEST_LLM_BASE_URL");
277 let model = std::env::var("COMINDLY_TEST_LLM_MODEL").expect("COMINDLY_TEST_LLM_MODEL");
278 let c = LlmClient::new(Some(&base), &model, None).unwrap();
279 let p = Prompts::default();
280 let e = c
281 .enrich_symbol(
282 &p,
283 "Symbol: open_pool\nKind: Function in repository acme, src/db/pool.rs:3\nSignature: fn open_pool(cfg: &DbConfig) -> Pool",
284 )
285 .await
286 .expect("summary");
287 assert!(!e.summary.is_empty());
288 eprintln!("summary: {}\nqueries: {:?}", e.summary, e.queries);
289 let (n, _) = c
290 .narrate_flow(
291 &p,
292 "handle_order",
293 "fn handle_order(o: Order)",
294 "d1 calls acme:validate src/a.rs:3",
295 )
296 .await
297 .expect("flow");
298 eprintln!("flow: {n}");
299 }
300
301 #[tokio::test]
302 #[ignore]
303 async fn live_guide_and_flow() {
304 let key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY");
305 let c = LlmClient::new(None, DEFAULT_MODEL, Some(&key)).unwrap();
306 let p = Prompts::default();
307 let evidence = "snake_case functions 97% (1234/1268)\nsrc/db/pool.rs: pub fn open_pool(cfg: &DbConfig) -> Pool";
308 let g = c
309 .guide_section(&p, "acme", "Naming", "How things are named", evidence)
310 .await
311 .expect("guide");
312 eprintln!("guide: {g:?}");
313 let (n, q) = c
314 .narrate_flow(
315 &p,
316 "handle_order",
317 "fn handle_order(o: Order)",
318 "d1 calls acme:validate src/a.rs:3\nd2 calls acme:save src/b.rs:9",
319 )
320 .await
321 .expect("flow");
322 assert!(!n.is_empty());
323 eprintln!("flow: {n}\n{q:?}");
324 }
325}