Skip to main content

codesynapse_core/
llm_backends.rs

1use serde_json::{json, Value};
2use std::path::{Path, PathBuf};
3
4use crate::error::{CodeSynapseError, Result};
5
6// ─── constants ────────────────────────────────────────────────────────────────
7
8const CHARS_PER_TOKEN: usize = 4;
9const FILE_CHAR_CAP: usize = 20_000;
10
11const CONTEXT_EXCEEDED_MARKERS: &[&str] = &[
12    "context size",
13    "context length",
14    "context_length",
15    "context window",
16    "n_keep",
17    "exceeds the available",
18    "n_ctx",
19    "maximum context",
20    "too many tokens",
21    "prompt is too long",
22    "context_length_exceeded",
23];
24
25// ─── result type ─────────────────────────────────────────────────────────────
26
27#[derive(Debug, Clone, Default)]
28pub struct LlmResult {
29    pub nodes: Vec<Value>,
30    pub edges: Vec<Value>,
31    pub hyperedges: Vec<Value>,
32    pub input_tokens: u64,
33    pub output_tokens: u64,
34    pub model: Option<String>,
35    pub finish_reason: String,
36}
37
38impl LlmResult {
39    pub fn empty(model: Option<String>) -> Self {
40        Self {
41            nodes: vec![],
42            edges: vec![],
43            hyperedges: vec![],
44            input_tokens: 0,
45            output_tokens: 0,
46            model,
47            finish_reason: "stop".to_string(),
48        }
49    }
50
51    fn merge(mut self, other: Self) -> Self {
52        self.nodes.extend(other.nodes);
53        self.edges.extend(other.edges);
54        self.hyperedges.extend(other.hyperedges);
55        self.input_tokens += other.input_tokens;
56        self.output_tokens += other.output_tokens;
57        self.finish_reason = "stop".to_string();
58        self
59    }
60}
61
62// ─── backend registry ────────────────────────────────────────────────────────
63
64#[derive(Debug, Clone)]
65pub struct BackendConfig {
66    pub base_url: String,
67    pub default_model: String,
68    pub env_keys: Vec<String>,
69    pub model_env_key: Option<String>,
70    pub temperature: Option<f64>,
71    pub reasoning_effort: Option<String>,
72    pub max_completion_tokens: usize,
73}
74
75fn all_backends() -> Vec<(&'static str, BackendConfig)> {
76    vec![
77        (
78            "gemini",
79            BackendConfig {
80                base_url: "https://generativelanguage.googleapis.com/v1beta/openai/".into(),
81                default_model: "gemini-3-flash-preview".into(),
82                env_keys: vec!["GEMINI_API_KEY".into(), "GOOGLE_API_KEY".into()],
83                model_env_key: Some("CODESYNAPSE_GEMINI_MODEL".into()),
84                temperature: Some(0.0),
85                reasoning_effort: Some("low".into()),
86                max_completion_tokens: 16384,
87            },
88        ),
89        (
90            "kimi",
91            BackendConfig {
92                base_url: "https://api.moonshot.ai/v1".into(),
93                default_model: "kimi-k2.6".into(),
94                env_keys: vec!["MOONSHOT_API_KEY".into()],
95                model_env_key: None,
96                temperature: None,
97                reasoning_effort: None,
98                max_completion_tokens: 16384,
99            },
100        ),
101        (
102            "claude",
103            BackendConfig {
104                base_url: "https://api.anthropic.com".into(),
105                default_model: "claude-sonnet-4-6".into(),
106                env_keys: vec!["ANTHROPIC_API_KEY".into()],
107                model_env_key: None,
108                temperature: Some(0.0),
109                reasoning_effort: None,
110                max_completion_tokens: 16384,
111            },
112        ),
113        (
114            "openai",
115            BackendConfig {
116                base_url: "https://api.openai.com/v1".into(),
117                default_model: "gpt-4.1-mini".into(),
118                env_keys: vec!["OPENAI_API_KEY".into()],
119                model_env_key: Some("CODESYNAPSE_OPENAI_MODEL".into()),
120                temperature: Some(0.0),
121                reasoning_effort: None,
122                max_completion_tokens: 8192,
123            },
124        ),
125        (
126            "deepseek",
127            BackendConfig {
128                base_url: "https://api.deepseek.com".into(),
129                default_model: "deepseek-v4-flash".into(),
130                env_keys: vec!["DEEPSEEK_API_KEY".into()],
131                model_env_key: Some("CODESYNAPSE_DEEPSEEK_MODEL".into()),
132                temperature: Some(0.0),
133                reasoning_effort: None,
134                max_completion_tokens: 16384,
135            },
136        ),
137        (
138            "ollama",
139            BackendConfig {
140                base_url: "http://localhost:11434/v1".into(),
141                default_model: "qwen2.5-coder:7b".into(),
142                env_keys: vec!["OLLAMA_API_KEY".into()],
143                model_env_key: None,
144                temperature: Some(0.0),
145                reasoning_effort: None,
146                max_completion_tokens: 16384,
147            },
148        ),
149    ]
150}
151
152fn get_backend_config(backend: &str) -> Option<BackendConfig> {
153    all_backends()
154        .into_iter()
155        .find(|(k, _)| *k == backend)
156        .map(|(_, v)| v)
157}
158
159// ─── pure functions ───────────────────────────────────────────────────────────
160
161pub fn get_backend_api_key(backend: &str) -> String {
162    if let Some(cfg) = get_backend_config(backend) {
163        for key in &cfg.env_keys {
164            if let Ok(val) = std::env::var(key) {
165                if !val.is_empty() {
166                    return val;
167                }
168            }
169        }
170    }
171    String::new()
172}
173
174pub fn detect_backend() -> Option<String> {
175    for backend in &["gemini", "kimi", "claude", "openai", "deepseek"] {
176        if !get_backend_api_key(backend).is_empty() {
177            return Some((*backend).to_string());
178        }
179    }
180    for key in &["AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION"] {
181        if std::env::var(key).map(|v| !v.is_empty()).unwrap_or(false) {
182            return Some("bedrock".to_string());
183        }
184    }
185    if let Ok(url) = std::env::var("OLLAMA_BASE_URL") {
186        if !url.is_empty() {
187            return Some("ollama".to_string());
188        }
189    }
190    None
191}
192
193pub fn looks_like_context_exceeded(msg: &str) -> bool {
194    let lower = msg.to_lowercase();
195    CONTEXT_EXCEEDED_MARKERS.iter().any(|m| lower.contains(m))
196}
197
198pub fn response_is_hollow(raw_content: Option<&str>, parsed: &Value) -> bool {
199    match raw_content {
200        None => return true,
201        Some(s) if s.trim().is_empty() => return true,
202        _ => {}
203    }
204    let nodes_empty = parsed
205        .get("nodes")
206        .and_then(Value::as_array)
207        .map(|v| v.is_empty())
208        .unwrap_or(true);
209    let edges_empty = parsed
210        .get("edges")
211        .and_then(Value::as_array)
212        .map(|v| v.is_empty())
213        .unwrap_or(true);
214    let hyper_empty = parsed
215        .get("hyperedges")
216        .and_then(Value::as_array)
217        .map(|v| v.is_empty())
218        .unwrap_or(true);
219    nodes_empty && edges_empty && hyper_empty
220}
221
222// ─── request building ────────────────────────────────────────────────────────
223
224#[derive(Debug)]
225pub struct OpenAiCompatRequest {
226    pub base_url: String,
227    pub api_key: String,
228    pub model: String,
229    pub user_message: String,
230    pub temperature: Option<f64>,
231    pub reasoning_effort: Option<String>,
232    pub max_completion_tokens: usize,
233    pub backend: String,
234}
235
236pub fn read_files(files: &[PathBuf], root: &Path) -> String {
237    let parts: Vec<String> = files
238        .iter()
239        .filter_map(|p| {
240            let rel = p.strip_prefix(root).unwrap_or(p);
241            let content = std::fs::read_to_string(p).ok()?;
242            let cap = content.len().min(FILE_CHAR_CAP);
243            Some(format!("=== {} ===\n{}", rel.display(), &content[..cap]))
244        })
245        .collect();
246    parts.join("\n\n")
247}
248
249fn format_env_keys(env_keys: &[String]) -> String {
250    env_keys.join(" or ")
251}
252
253fn default_model_for(cfg: &BackendConfig) -> String {
254    if let Some(ref key) = cfg.model_env_key {
255        if let Ok(val) = std::env::var(key) {
256            if !val.is_empty() {
257                return val;
258            }
259        }
260    }
261    cfg.default_model.clone()
262}
263
264pub fn build_extract_request(
265    files: &[PathBuf],
266    backend: &str,
267    root: &Path,
268    api_key: Option<&str>,
269    model: Option<&str>,
270) -> Result<OpenAiCompatRequest> {
271    let cfg = get_backend_config(backend)
272        .ok_or_else(|| CodeSynapseError::Validation(format!("Unknown backend: {backend}")))?;
273
274    let key = match api_key {
275        Some(k) if !k.is_empty() => k.to_string(),
276        _ => get_backend_api_key(backend),
277    };
278
279    if key.is_empty() {
280        let key_names = format_env_keys(&cfg.env_keys);
281        return Err(CodeSynapseError::Validation(format!(
282            "No API key for backend '{backend}'. Set {key_names} or pass api_key="
283        )));
284    }
285
286    let mdl = model
287        .map(|s| s.to_string())
288        .unwrap_or_else(|| default_model_for(&cfg));
289    let user_message = read_files(files, root);
290
291    Ok(OpenAiCompatRequest {
292        base_url: cfg.base_url.clone(),
293        api_key: key,
294        model: mdl,
295        user_message,
296        temperature: cfg.temperature,
297        reasoning_effort: cfg.reasoning_effort.clone(),
298        max_completion_tokens: cfg.max_completion_tokens,
299        backend: backend.to_string(),
300    })
301}
302
303// ─── Ollama extra body ────────────────────────────────────────────────────────
304
305#[derive(Debug, Clone)]
306pub struct OllamaExtraBody {
307    pub num_ctx: usize,
308    pub keep_alive: String,
309}
310
311pub fn compute_ollama_num_ctx(user_message_len: usize, max_completion_tokens: usize) -> usize {
312    if let Ok(raw) = std::env::var("CODESYNAPSE_OLLAMA_NUM_CTX") {
313        if let Ok(v) = raw.trim().parse::<usize>() {
314            return v;
315        }
316    }
317    let estimated_input = user_message_len / CHARS_PER_TOKEN + 400;
318    let auto = (estimated_input + max_completion_tokens + 2000).min(131_072);
319    auto.max(8192)
320}
321
322pub fn build_extra_body(
323    backend: &str,
324    user_message: &str,
325    max_completion_tokens: usize,
326) -> Option<OllamaExtraBody> {
327    if backend != "ollama" {
328        return None;
329    }
330    let num_ctx = compute_ollama_num_ctx(user_message.len(), max_completion_tokens);
331    let keep_alive =
332        std::env::var("CODESYNAPSE_OLLAMA_KEEP_ALIVE").unwrap_or_else(|_| "30m".to_string());
333    Some(OllamaExtraBody {
334        num_ctx,
335        keep_alive,
336    })
337}
338
339// ─── response processing ─────────────────────────────────────────────────────
340
341fn parse_llm_json(raw: &str) -> Value {
342    let stripped = if raw.starts_with("```") {
343        let after_fence = raw.split("```").nth(1).unwrap_or("");
344        let after_lang = after_fence.strip_prefix("json").unwrap_or(after_fence);
345        after_lang.rsplit("```").last().unwrap_or(after_lang).trim()
346    } else {
347        raw.trim()
348    };
349    serde_json::from_str(stripped)
350        .unwrap_or_else(|_| json!({"nodes": [], "edges": [], "hyperedges": []}))
351}
352
353pub fn process_openai_compat_response(
354    raw_content: Option<&str>,
355    finish_reason: &str,
356    prompt_tokens: u64,
357    completion_tokens: u64,
358    model: &str,
359    backend: &str,
360) -> LlmResult {
361    let parsed = match raw_content {
362        Some(s) if !s.trim().is_empty() => parse_llm_json(s),
363        _ => json!({"nodes": [], "edges": [], "hyperedges": []}),
364    };
365
366    let hollow = response_is_hollow(raw_content, &parsed);
367    let effective_finish_reason = if hollow && finish_reason != "length" {
368        let _ = backend;
369        "length".to_string()
370    } else {
371        finish_reason.to_string()
372    };
373
374    LlmResult {
375        nodes: parsed
376            .get("nodes")
377            .and_then(Value::as_array)
378            .cloned()
379            .unwrap_or_default(),
380        edges: parsed
381            .get("edges")
382            .and_then(Value::as_array)
383            .cloned()
384            .unwrap_or_default(),
385        hyperedges: parsed
386            .get("hyperedges")
387            .and_then(Value::as_array)
388            .cloned()
389            .unwrap_or_default(),
390        input_tokens: prompt_tokens,
391        output_tokens: completion_tokens,
392        model: Some(model.to_string()),
393        finish_reason: effective_finish_reason,
394    }
395}
396
397// ─── adaptive retry ───────────────────────────────────────────────────────────
398
399#[allow(clippy::only_used_in_recursion)]
400pub fn extract_with_adaptive_retry<F>(
401    chunk: &[PathBuf],
402    backend: &str,
403    max_depth: usize,
404    depth: usize,
405    extractor: &F,
406) -> Result<LlmResult>
407where
408    F: Fn(&[PathBuf]) -> Result<LlmResult>,
409{
410    let result = match extractor(chunk) {
411        Ok(r) => r,
412        Err(e) => {
413            let msg = e.to_string();
414            if !looks_like_context_exceeded(&msg) {
415                return Err(e);
416            }
417            if chunk.len() <= 1 {
418                return Ok(LlmResult::empty(None));
419            }
420            if depth >= max_depth {
421                return Ok(LlmResult::empty(None));
422            }
423            let mid = chunk.len() / 2;
424            let left = extract_with_adaptive_retry(
425                &chunk[..mid],
426                backend,
427                max_depth,
428                depth + 1,
429                extractor,
430            )?;
431            let right = extract_with_adaptive_retry(
432                &chunk[mid..],
433                backend,
434                max_depth,
435                depth + 1,
436                extractor,
437            )?;
438            return Ok(left.merge(right));
439        }
440    };
441
442    if result.finish_reason != "length" {
443        return Ok(result);
444    }
445
446    if chunk.len() <= 1 || depth >= max_depth {
447        return Ok(result);
448    }
449
450    let mid = chunk.len() / 2;
451    let left =
452        extract_with_adaptive_retry(&chunk[..mid], backend, max_depth, depth + 1, extractor)?;
453    let right =
454        extract_with_adaptive_retry(&chunk[mid..], backend, max_depth, depth + 1, extractor)?;
455    Ok(left.merge(right))
456}
457
458// ─── corpus parallel ──────────────────────────────────────────────────────────
459
460pub fn effective_max_concurrency(backend: &str, requested: usize) -> usize {
461    if backend == "ollama" {
462        let parallel = std::env::var("CODESYNAPSE_OLLAMA_PARALLEL").unwrap_or_default();
463        if parallel.trim() != "1" {
464            return 1;
465        }
466    }
467    requested
468}
469
470pub fn extract_corpus_parallel_with<F>(
471    files: &[PathBuf],
472    backend: &str,
473    chunk_size: usize,
474    max_concurrency: usize,
475    max_retry_depth: usize,
476    extractor: F,
477) -> LlmResult
478where
479    F: Fn(&[PathBuf]) -> Result<LlmResult> + Send + Sync + 'static,
480{
481    let chunks: Vec<Vec<PathBuf>> = files
482        .chunks(chunk_size.max(1))
483        .map(|c| c.to_vec())
484        .collect();
485    let total = chunks.len();
486    let workers = effective_max_concurrency(backend, max_concurrency.max(1).min(total.max(1)));
487
488    let mut merged = LlmResult {
489        finish_reason: "stop".to_string(),
490        ..Default::default()
491    };
492
493    let accumulate = |acc: &mut LlmResult, r: LlmResult| {
494        acc.nodes.extend(r.nodes);
495        acc.edges.extend(r.edges);
496        acc.hyperedges.extend(r.hyperedges);
497        acc.input_tokens += r.input_tokens;
498        acc.output_tokens += r.output_tokens;
499    };
500
501    if workers <= 1 {
502        for (idx, chunk) in chunks.iter().enumerate() {
503            match extract_with_adaptive_retry(chunk, backend, max_retry_depth, 0, &extractor) {
504                Ok(r) => accumulate(&mut merged, r),
505                Err(e) => eprintln!("[codesynapse] chunk {}/{total} failed: {e}", idx + 1),
506            }
507        }
508    } else {
509        use std::sync::{Arc, Mutex};
510        let extractor = Arc::new(extractor);
511        let acc: Arc<Mutex<LlmResult>> = Arc::new(Mutex::new(LlmResult {
512            finish_reason: "stop".to_string(),
513            ..Default::default()
514        }));
515        let mut handles = vec![];
516
517        for (idx, chunk) in chunks.into_iter().enumerate() {
518            let ext = extractor.clone();
519            let acc2 = acc.clone();
520            let be = backend.to_string();
521            let handle = std::thread::spawn(move || {
522                match extract_with_adaptive_retry(&chunk, &be, max_retry_depth, 0, &*ext) {
523                    Ok(r) => {
524                        let mut guard = acc2.lock().unwrap();
525                        guard.nodes.extend(r.nodes);
526                        guard.edges.extend(r.edges);
527                        guard.hyperedges.extend(r.hyperedges);
528                        guard.input_tokens += r.input_tokens;
529                        guard.output_tokens += r.output_tokens;
530                    }
531                    Err(e) => eprintln!("[codesynapse] chunk {}/{total} failed: {e}", idx + 1),
532                }
533            });
534            handles.push(handle);
535        }
536        for h in handles {
537            let _ = h.join();
538        }
539        let inner = Arc::try_unwrap(acc).unwrap().into_inner().unwrap();
540        merged = inner;
541        merged.finish_reason = "stop".to_string();
542    }
543
544    merged
545}
546
547// ─── tests ───────────────────────────────────────────────────────────────────
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use std::sync::{
553        atomic::{AtomicUsize, Ordering},
554        Arc, Mutex,
555    };
556
557    static ENV_LOCK: Mutex<()> = Mutex::new(());
558
559    const ALL_BACKEND_KEYS: &[&str] = &[
560        "GEMINI_API_KEY",
561        "GOOGLE_API_KEY",
562        "MOONSHOT_API_KEY",
563        "ANTHROPIC_API_KEY",
564        "OPENAI_API_KEY",
565        "DEEPSEEK_API_KEY",
566        "AWS_PROFILE",
567        "AWS_REGION",
568        "AWS_DEFAULT_REGION",
569        "OLLAMA_BASE_URL",
570    ];
571
572    fn with_env<R, F: FnOnce() -> R>(set: &[(&str, &str)], clear: &[&str], f: F) -> R {
573        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
574        for &k in clear {
575            std::env::remove_var(k);
576        }
577        for &(k, v) in set {
578            std::env::set_var(k, v);
579        }
580        let r = f();
581        for &(k, _) in set {
582            std::env::remove_var(k);
583        }
584        r
585    }
586
587    // ── 1. detect_backend: gemini via GEMINI_API_KEY ──────────────────────────
588
589    #[test]
590    fn test_gemini_accepts_gemini_api_key() {
591        with_env(
592            &[("GEMINI_API_KEY", "gemini-key")],
593            ALL_BACKEND_KEYS,
594            || {
595                assert_eq!(detect_backend().as_deref(), Some("gemini"));
596                assert_eq!(get_backend_api_key("gemini"), "gemini-key");
597            },
598        );
599    }
600
601    // ── 2. detect_backend: gemini via GOOGLE_API_KEY ──────────────────────────
602
603    #[test]
604    fn test_gemini_accepts_google_api_key() {
605        with_env(
606            &[("GOOGLE_API_KEY", "google-key")],
607            ALL_BACKEND_KEYS,
608            || {
609                assert_eq!(detect_backend().as_deref(), Some("gemini"));
610                assert_eq!(get_backend_api_key("gemini"), "google-key");
611            },
612        );
613    }
614
615    // ── 3. detect_backend: gemini wins over all others ────────────────────────
616
617    #[test]
618    fn test_backend_detection_prefers_gemini() {
619        with_env(
620            &[
621                ("OPENAI_API_KEY", "openai-key"),
622                ("ANTHROPIC_API_KEY", "anthropic-key"),
623                ("MOONSHOT_API_KEY", "moonshot-key"),
624                ("GEMINI_API_KEY", "gemini-key"),
625            ],
626            ALL_BACKEND_KEYS,
627            || {
628                assert_eq!(detect_backend().as_deref(), Some("gemini"));
629            },
630        );
631    }
632
633    // ── 4. detect_backend: openai ─────────────────────────────────────────────
634
635    #[test]
636    fn test_openai_backend_detected() {
637        with_env(
638            &[("OPENAI_API_KEY", "openai-key")],
639            ALL_BACKEND_KEYS,
640            || {
641                assert_eq!(detect_backend().as_deref(), Some("openai"));
642                assert_eq!(get_backend_api_key("openai"), "openai-key");
643            },
644        );
645    }
646
647    // ── 5. build_extract_request: gemini routes through openai compat ─────────
648
649    #[test]
650    fn test_extract_files_direct_routes_gemini_through_openai_compat() {
651        let dir = tempfile::tempdir().unwrap();
652        let source = dir.path().join("note.md");
653        std::fs::write(&source, "# Architecture\n\nThe runner emits a snapshot.\n").unwrap();
654
655        with_env(
656            &[("GOOGLE_API_KEY", "google-key")],
657            ALL_BACKEND_KEYS,
658            || {
659                let req = build_extract_request(
660                    std::slice::from_ref(&source),
661                    "gemini",
662                    dir.path(),
663                    None,
664                    None,
665                )
666                .unwrap();
667                assert_eq!(
668                    req.base_url,
669                    "https://generativelanguage.googleapis.com/v1beta/openai/"
670                );
671                assert_eq!(req.api_key, "google-key");
672                assert_eq!(req.model, "gemini-3-flash-preview");
673                assert_eq!(
674                    req.user_message,
675                    "=== note.md ===\n# Architecture\n\nThe runner emits a snapshot.\n"
676                );
677                assert_eq!(req.temperature, Some(0.0));
678                assert_eq!(req.reasoning_effort.as_deref(), Some("low"));
679                assert_eq!(req.max_completion_tokens, 16384);
680            },
681        );
682    }
683
684    // ── 6. CODESYNAPSE_GEMINI_MODEL overrides default model ─────────────────────
685
686    #[test]
687    fn test_gemini_model_can_be_overridden_by_env() {
688        let dir = tempfile::tempdir().unwrap();
689        let source = dir.path().join("note.md");
690        std::fs::write(&source, "# Architecture\n").unwrap();
691
692        with_env(
693            &[
694                ("GOOGLE_API_KEY", "google-key"),
695                ("CODESYNAPSE_GEMINI_MODEL", "gemini-3.1-pro-preview"),
696            ],
697            ALL_BACKEND_KEYS,
698            || {
699                let req = build_extract_request(
700                    std::slice::from_ref(&source),
701                    "gemini",
702                    dir.path(),
703                    None,
704                    None,
705                )
706                .unwrap();
707                assert_eq!(req.model, "gemini-3.1-pro-preview");
708            },
709        );
710    }
711
712    // ── 7. missing gemini key names both env vars in error message ─────────────
713
714    #[test]
715    fn test_missing_gemini_key_names_both_supported_env_vars() {
716        with_env(&[], ALL_BACKEND_KEYS, || {
717            let err = build_extract_request(&[], "gemini", Path::new("."), None, None)
718                .unwrap_err()
719                .to_string();
720            assert!(
721                err.contains("GEMINI_API_KEY") && err.contains("GOOGLE_API_KEY"),
722                "error should name both keys, got: {err}"
723            );
724        });
725    }
726
727    // ── 8. looks_like_context_exceeded: matches known messages ────────────────
728
729    #[test]
730    fn test_looks_like_context_exceeded_matches_common_messages() {
731        let msgs = [
732            "Error code: 400 - {'error': 'Context size has been exceeded.'}",
733            "n_keep: 22374 >= n_ctx: 4096",
734            "context_length_exceeded: This model's maximum context length is 8192 tokens",
735            "exceeds the available context size",
736            "The prompt is too long for this model.",
737        ];
738        for m in &msgs {
739            assert!(looks_like_context_exceeded(m), "should match: {m}");
740        }
741    }
742
743    // ── 9. looks_like_context_exceeded: ignores unrelated errors ─────────────
744
745    #[test]
746    fn test_looks_like_context_exceeded_ignores_unrelated_errors() {
747        let msgs = [
748            "timeout",
749            "rate limit",
750            "401 unauthorized",
751            "connection refused",
752        ];
753        for m in &msgs {
754            assert!(!looks_like_context_exceeded(m), "should not match: {m}");
755        }
756    }
757
758    // ── 10. adaptive_retry: bisects on context exceeded ───────────────────────
759
760    #[test]
761    fn test_adaptive_retry_splits_on_context_exceeded() {
762        let dir = tempfile::tempdir().unwrap();
763        let files: Vec<PathBuf> = (0..4)
764            .map(|i| {
765                let p = dir.path().join(format!("f{i}.md"));
766                std::fs::write(&p, "hello").unwrap();
767                p
768            })
769            .collect();
770
771        let call_count = Arc::new(AtomicUsize::new(0));
772        let cc = call_count.clone();
773
774        let extractor = move |chunk: &[PathBuf]| -> Result<LlmResult> {
775            cc.fetch_add(1, Ordering::SeqCst);
776            if chunk.len() == 4 {
777                return Err(CodeSynapseError::Validation(
778                    "Error 400: Context size has been exceeded.".into(),
779                ));
780            }
781            Ok(LlmResult {
782                nodes: chunk
783                    .iter()
784                    .map(|f| json!({"id": f.file_stem().unwrap().to_str().unwrap()}))
785                    .collect(),
786                finish_reason: "stop".to_string(),
787                ..Default::default()
788            })
789        };
790
791        let result = extract_with_adaptive_retry(&files, "kimi", 3, 0, &extractor).unwrap();
792
793        assert_eq!(result.nodes.len(), 4);
794        assert_eq!(call_count.load(Ordering::SeqCst), 3);
795    }
796
797    // ── 11. adaptive_retry: single-file overflow returns empty fragment ────────
798
799    #[test]
800    fn test_adaptive_retry_gives_up_on_single_file_overflow() {
801        let dir = tempfile::tempdir().unwrap();
802        let f = dir.path().join("huge.md");
803        std::fs::write(&f, "x").unwrap();
804
805        let extractor = |_: &[PathBuf]| -> Result<LlmResult> {
806            Err(CodeSynapseError::Validation(
807                "context_length_exceeded".into(),
808            ))
809        };
810
811        let result = extract_with_adaptive_retry(&[f], "kimi", 3, 0, &extractor).unwrap();
812        assert_eq!(result.nodes.len(), 0);
813        assert_eq!(result.edges.len(), 0);
814        assert_eq!(result.finish_reason, "stop");
815    }
816
817    // ── 12. adaptive_retry: re-raises unrelated errors ────────────────────────
818
819    #[test]
820    fn test_adaptive_retry_re_raises_unrelated_errors() {
821        let dir = tempfile::tempdir().unwrap();
822        let f = dir.path().join("f.md");
823        std::fs::write(&f, "x").unwrap();
824
825        let extractor = |_: &[PathBuf]| -> Result<LlmResult> {
826            Err(CodeSynapseError::Validation("rate limit hit".into()))
827        };
828
829        let err = extract_with_adaptive_retry(&[f], "kimi", 3, 0, &extractor).unwrap_err();
830        assert!(err.to_string().contains("rate limit"));
831    }
832
833    // ── 13. response_is_hollow: empty string ─────────────────────────────────
834
835    #[test]
836    fn test_response_is_hollow_flags_empty_string() {
837        let parsed = json!({"nodes": [], "edges": [], "hyperedges": []});
838        assert!(response_is_hollow(Some(""), &parsed));
839    }
840
841    // ── 14. response_is_hollow: None content ─────────────────────────────────
842
843    #[test]
844    fn test_response_is_hollow_flags_none_content() {
845        let parsed = json!({"nodes": [], "edges": [], "hyperedges": []});
846        assert!(response_is_hollow(None, &parsed));
847    }
848
849    // ── 15. response_is_hollow: whitespace only ───────────────────────────────
850
851    #[test]
852    fn test_response_is_hollow_flags_whitespace_only() {
853        let parsed = json!({"nodes": [], "edges": [], "hyperedges": []});
854        assert!(response_is_hollow(Some("   \n\t  "), &parsed));
855    }
856
857    // ── 16. response_is_hollow: parsed but no nodes/edges ────────────────────
858
859    #[test]
860    fn test_response_is_hollow_flags_parsed_but_no_nodes_or_edges() {
861        assert!(response_is_hollow(
862            Some(r#"{"sorry": "I cannot"}"#),
863            &json!({})
864        ));
865        assert!(response_is_hollow(
866            Some("{}"),
867            &json!({"nodes": [], "edges": [], "hyperedges": []})
868        ));
869    }
870
871    // ── 17. response_is_hollow: real extraction is not hollow ────────────────
872
873    #[test]
874    fn test_response_is_hollow_accepts_real_extraction() {
875        let parsed = json!({"nodes": [{"id": "x"}], "edges": [], "hyperedges": []});
876        assert!(!response_is_hollow(
877            Some(r#"{"nodes":[{"id":"x"}]}"#),
878            &parsed
879        ));
880
881        let parsed2 =
882            json!({"nodes": [], "edges": [{"source": "a", "target": "b"}], "hyperedges": []});
883        assert!(!response_is_hollow(Some(r#"{"edges":[...]}"#), &parsed2));
884    }
885
886    // ── 18. process response: empty content → finish_reason = "length" ────────
887
888    #[test]
889    fn test_call_openai_compat_relabels_empty_content_as_length() {
890        let result =
891            process_openai_compat_response(Some(""), "stop", 100, 0, "qwen2.5-coder:7b", "ollama");
892        assert_eq!(
893            result.finish_reason, "length",
894            "empty content from a 'successful' call must be re-labelled to trigger bisection"
895        );
896    }
897
898    // ── 19. process response: None content → finish_reason = "length" ─────────
899
900    #[test]
901    fn test_call_openai_compat_relabels_none_content_as_length() {
902        let result =
903            process_openai_compat_response(None, "stop", 100, 0, "qwen2.5-coder:7b", "ollama");
904        assert_eq!(result.finish_reason, "length");
905    }
906
907    // ── 20. process response: unparseable JSON → finish_reason = "length" ─────
908
909    #[test]
910    fn test_call_openai_compat_relabels_unparseable_json_as_length() {
911        let result = process_openai_compat_response(
912            Some(r#"{"nodes": [{"id":"#),
913            "stop",
914            100,
915            20,
916            "qwen2.5-coder:7b",
917            "ollama",
918        );
919        assert_eq!(result.finish_reason, "length");
920    }
921
922    // ── 21. process response: real extraction preserves finish_reason ──────────
923
924    #[test]
925    fn test_call_openai_compat_preserves_real_finish_reason() {
926        let result = process_openai_compat_response(
927            Some(r#"{"nodes":[{"id":"a"}],"edges":[],"hyperedges":[]}"#),
928            "stop",
929            100,
930            200,
931            "m",
932            "kimi",
933        );
934        assert_eq!(result.finish_reason, "stop");
935        assert_eq!(result.nodes.len(), 1);
936    }
937
938    // ── 22. Ollama extra_body: num_ctx and keep_alive ─────────────────────────
939
940    #[test]
941    fn test_ollama_extra_body_sets_num_ctx_and_keep_alive() {
942        with_env(
943            &[],
944            &[
945                "CODESYNAPSE_OLLAMA_NUM_CTX",
946                "CODESYNAPSE_OLLAMA_KEEP_ALIVE",
947            ],
948            || {
949                let body = build_extra_body("ollama", "user msg", 8192).unwrap();
950                assert!(
951                    body.num_ctx >= 8192,
952                    "num_ctx must be at least the floor value, got {}",
953                    body.num_ctx
954                );
955                assert_eq!(body.keep_alive, "30m");
956            },
957        );
958    }
959
960    // ── 23. Ollama num_ctx scales with small token budget ─────────────────────
961
962    #[test]
963    fn test_ollama_num_ctx_scales_with_small_token_budget() {
964        with_env(
965            &[],
966            &[
967                "CODESYNAPSE_OLLAMA_NUM_CTX",
968                "CODESYNAPSE_OLLAMA_KEEP_ALIVE",
969            ],
970            || {
971                let small_msg = "x".repeat(32_000);
972                let num_ctx = compute_ollama_num_ctx(small_msg.len(), 16384);
973                assert!(
974                    num_ctx < 131_072,
975                    "num_ctx={num_ctx} is too large for a small chunk; wastes VRAM (#798)"
976                );
977                assert!(
978                    num_ctx >= 8192,
979                    "num_ctx must cover at least the output cap"
980                );
981            },
982        );
983    }
984
985    // ── 24. Ollama num_ctx env override ──────────────────────────────────────
986
987    #[test]
988    fn test_ollama_num_ctx_env_override() {
989        with_env(
990            &[("CODESYNAPSE_OLLAMA_NUM_CTX", "65536")],
991            &["CODESYNAPSE_OLLAMA_KEEP_ALIVE"],
992            || {
993                let num_ctx = compute_ollama_num_ctx(100, 8192);
994                assert_eq!(num_ctx, 65536);
995            },
996        );
997    }
998
999    // ── 25. non-Ollama backend gets no extra_body ─────────────────────────────
1000
1001    #[test]
1002    fn test_non_ollama_backend_gets_no_num_ctx_extra_body() {
1003        let body = build_extra_body("openai", "u", 8192);
1004        assert!(
1005            body.is_none(),
1006            "non-ollama backends must not get num_ctx injection"
1007        );
1008    }
1009
1010    // ── 26. extract_corpus_parallel: Ollama runs serially ────────────────────
1011
1012    #[test]
1013    fn test_extract_corpus_parallel_ollama_runs_serially() {
1014        with_env(&[], &["CODESYNAPSE_OLLAMA_PARALLEL"], || {
1015            let dir = tempfile::tempdir().unwrap();
1016            let files: Vec<PathBuf> = (0..6)
1017                .map(|i| {
1018                    let p = dir.path().join(format!("f{i}.md"));
1019                    std::fs::write(&p, "hello").unwrap();
1020                    p
1021                })
1022                .collect();
1023
1024            let extractor = |chunk: &[PathBuf]| -> Result<LlmResult> {
1025                Ok(LlmResult {
1026                    nodes: chunk
1027                        .iter()
1028                        .map(|f| json!({"id": f.file_stem().unwrap().to_str().unwrap()}))
1029                        .collect(),
1030                    finish_reason: "stop".to_string(),
1031                    ..Default::default()
1032                })
1033            };
1034
1035            let result = extract_corpus_parallel_with(&files, "ollama", 2, 4, 3, extractor);
1036            assert_eq!(result.nodes.len(), 6);
1037        });
1038    }
1039
1040    // ── 27. extract_corpus_parallel: CODESYNAPSE_OLLAMA_PARALLEL=1 uses concurrency
1041
1042    #[test]
1043    fn test_extract_corpus_parallel_ollama_parallel_env_restores_concurrency() {
1044        with_env(&[("CODESYNAPSE_OLLAMA_PARALLEL", "1")], &[], || {
1045            let workers = effective_max_concurrency("ollama", 4);
1046            assert_eq!(
1047                workers, 4,
1048                "CODESYNAPSE_OLLAMA_PARALLEL=1 should restore concurrency"
1049            );
1050        });
1051    }
1052
1053    // ── 28. adaptive_retry: bisects on hollow Ollama response ────────────────
1054
1055    #[test]
1056    fn test_adaptive_retry_bisects_on_hollow_ollama_response() {
1057        let dir = tempfile::tempdir().unwrap();
1058        let files: Vec<PathBuf> = (0..4)
1059            .map(|i| {
1060                let p = dir.path().join(format!("f{i}.md"));
1061                std::fs::write(&p, "hello").unwrap();
1062                p
1063            })
1064            .collect();
1065
1066        let call_count = Arc::new(AtomicUsize::new(0));
1067        let cc = call_count.clone();
1068
1069        let extractor = move |chunk: &[PathBuf]| -> Result<LlmResult> {
1070            cc.fetch_add(1, Ordering::SeqCst);
1071            if chunk.len() == 4 {
1072                return Ok(LlmResult {
1073                    nodes: vec![],
1074                    edges: vec![],
1075                    hyperedges: vec![],
1076                    input_tokens: 100,
1077                    output_tokens: 0,
1078                    model: Some("m".into()),
1079                    finish_reason: "length".to_string(),
1080                });
1081            }
1082            Ok(LlmResult {
1083                nodes: chunk
1084                    .iter()
1085                    .map(|f| json!({"id": f.file_stem().unwrap().to_str().unwrap()}))
1086                    .collect(),
1087                finish_reason: "stop".to_string(),
1088                ..Default::default()
1089            })
1090        };
1091
1092        let result = extract_with_adaptive_retry(&files, "ollama", 3, 0, &extractor).unwrap();
1093
1094        assert_eq!(
1095            result.nodes.len(),
1096            4,
1097            "bisection should recover all 4 nodes after hollow response"
1098        );
1099        assert_eq!(call_count.load(Ordering::SeqCst), 3);
1100    }
1101}