1use serde_json::{json, Value};
2use std::path::{Path, PathBuf};
3
4use crate::error::{CodeSynapseError, Result};
5
6const 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#[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#[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
159pub 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#[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#[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
339fn 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#[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
458pub 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}