1use anyhow::Context as _;
31use oxibrain_core::extraction::ExtractMechanism;
32use oxibrain_ports::{LlmPort, TokenizerPort};
33use std::sync::Arc;
34
35use crate::cmd::foundation::{
36 self, FoundationError, ProfileRole, ProviderKind, ProviderProfile, ResolvedProfiles,
37 SecretResolver, default_secret_resolver,
38};
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Provider {
44 Anthropic,
45 OpenAi,
46 Local,
47}
48
49#[derive(Debug, Clone)]
54#[allow(dead_code)]
60pub enum ResolutionSource {
61 ExplicitOverride(Provider),
63 FoundationProfile {
68 profile_id: String,
69 provider: ProviderKind,
70 model_id: String,
71 mechanism: ExtractMechanism,
72 },
73 CompatEnv {
77 kind: ProviderKind,
78 model_id: String,
79 },
80 Local,
82}
83
84pub struct ProviderLlm {
89 pub port: Arc<dyn LlmPort>,
90 pub model_id: String,
91 pub mechanism: ExtractMechanism,
92 pub model_digest: Option<String>,
95 pub tokenizer: Option<Arc<dyn TokenizerPort>>,
96 pub source: ResolutionSource,
100}
101
102impl ProviderLlm {
103 pub fn profile_id(&self) -> Option<String> {
109 match &self.source {
110 ResolutionSource::FoundationProfile { profile_id, .. } => Some(profile_id.clone()),
111 ResolutionSource::ExplicitOverride(_)
112 | ResolutionSource::CompatEnv { .. }
113 | ResolutionSource::Local => None,
114 }
115 }
116}
117
118pub fn resolve_provider(
123 explicit: Option<&str>,
124 anthropic_key_present: bool,
125 openai_key_present: bool,
126) -> anyhow::Result<Provider> {
127 match explicit {
128 Some("anthropic") => Ok(Provider::Anthropic),
129 Some("openai") => Ok(Provider::OpenAi),
130 Some("local") => Ok(Provider::Local),
131 Some(other) => anyhow::bail!(
132 "unknown OXIBRAIN_LLM_PROVIDER={other} (expected: anthropic|openai|local)"
133 ),
134 None if anthropic_key_present => Ok(Provider::Anthropic),
137 None if openai_key_present => Ok(Provider::OpenAi),
138 None => Ok(Provider::Local),
139 }
140}
141
142pub fn resolve_role() -> ProfileRole {
146 if let Ok(raw) = std::env::var("OXIBRAIN_LLM_ROLE") {
147 if let Some(role) = ProfileRole::parse(&raw) {
148 return role;
149 }
150 tracing::warn!(
154 role = %raw,
155 "OXIBRAIN_LLM_ROLE is not a known role; falling back to memory.extract"
156 );
157 }
158 ProfileRole::MemoryExtract
159}
160
161pub async fn from_env() -> anyhow::Result<ProviderLlm> {
165 from_env_for_role(resolve_role()).await
166}
167
168pub async fn from_env_for_role(role: ProfileRole) -> anyhow::Result<ProviderLlm> {
174 let explicit = std::env::var("OXIBRAIN_LLM_PROVIDER").ok();
175 let anthropic_key_present = std::env::var("ANTHROPIC_API_KEY").is_ok();
176 let openai_key_present = std::env::var("OPENAI_API_KEY").is_ok();
177
178 if let Some(name) = explicit.as_deref() {
180 match resolve_provider(Some(name), anthropic_key_present, openai_key_present)? {
181 Provider::Anthropic => return anthropic_from_env(),
182 Provider::OpenAi => return openai_from_env(),
183 Provider::Local => return local_from_manifest().await,
184 }
185 }
186
187 let resolved_profiles =
190 foundation::load_profiles(&foundation::foundation_home()).map_err(anyhow::Error::msg)?;
191 if let Some(profiles) = resolved_profiles {
192 if let Some(provider) =
193 try_foundation_profile(&profiles, role, default_secret_resolver().as_ref()).await?
194 {
195 return Ok(provider);
196 }
197 }
198
199 if anthropic_key_present {
201 return anthropic_from_env();
202 }
203 if openai_key_present {
204 return openai_from_env();
205 }
206
207 local_from_manifest().await
209}
210
211#[doc(hidden)]
220pub async fn try_foundation_profile(
221 profiles: &ResolvedProfiles,
222 role: ProfileRole,
223 secret_resolver: &dyn SecretResolver,
224) -> anyhow::Result<Option<ProviderLlm>> {
225 let mut selected_profile: Option<&ProviderProfile> = None;
241 for profile in profiles.iter() {
242 if !profile.roles.contains(&role) {
243 continue;
244 }
245 let mechanism = match ProviderKind::parse(&profile.provider) {
246 Some(ProviderKind::OpenAi) => ExtractMechanism::JsonSchema,
247 Some(ProviderKind::Anthropic) | None => ExtractMechanism::ToolCall,
248 };
249 if !profile.capabilities.clone().satisfies(mechanism) {
250 anyhow::bail!(
251 "Foundation profile `{}` rejected: declared capabilities do not satisfy extraction mechanism {:?}",
252 profile.id,
253 mechanism
254 );
255 }
256 selected_profile = Some(profile);
257 break;
258 }
259 let profile = match selected_profile {
260 Some(p) => p,
261 None => return Ok(None),
262 };
263 let mechanism = match ProviderKind::parse(&profile.provider) {
264 Some(ProviderKind::OpenAi) => ExtractMechanism::JsonSchema,
265 _ => ExtractMechanism::ToolCall,
266 };
267
268 let secret = match secret_resolver.resolve(&profile.credential) {
272 Ok(s) => s,
273 Err(e @ FoundationError::SecretUnavailable { .. }) => {
274 tracing::warn!("{e}");
275 return Ok(None);
276 }
277 Err(other) => return Err(anyhow::Error::msg(other.to_string())),
278 };
279
280 let provider_kind = ProviderKind::parse(&profile.provider).ok_or_else(|| {
281 anyhow::anyhow!(
282 "Foundation profile `{}` has unknown provider `{}`",
283 profile.id,
284 profile.provider
285 )
286 })?;
287
288 let port: Arc<dyn LlmPort> = match provider_kind {
289 ProviderKind::Anthropic => Arc::new(oxibrain_llm_http::AnthropicLlm::new(
290 secret,
291 profile.model.clone(),
292 )),
293 ProviderKind::OpenAi => Arc::new(oxibrain_llm_http::OpenAiLlm::new(
294 secret,
295 profile.model.clone(),
296 )),
297 };
298
299 Ok(Some(ProviderLlm {
300 port,
301 model_id: profile.model.clone(),
302 mechanism,
303 model_digest: None,
304 tokenizer: None,
305 source: ResolutionSource::FoundationProfile {
306 profile_id: profile.id.clone(),
307 provider: provider_kind,
308 model_id: profile.model.clone(),
309 mechanism,
310 },
311 }))
312}
313
314fn anthropic_from_env() -> anyhow::Result<ProviderLlm> {
315 let key = std::env::var("ANTHROPIC_API_KEY")
316 .map_err(|_| anyhow::anyhow!("ANTHROPIC_API_KEY not set (required for extraction)"))?;
317 let model = std::env::var("ANTHROPIC_MODEL")
318 .or_else(|_| std::env::var("OXIBRAIN_MODEL"))
319 .unwrap_or_else(|_| "claude-sonnet-4-5".to_string());
320 Ok(ProviderLlm {
321 port: Arc::new(oxibrain_llm_http::AnthropicLlm::new(key, model.clone())),
322 model_id: model.clone(),
323 mechanism: ExtractMechanism::ToolCall,
324 model_digest: None,
325 tokenizer: None,
326 source: ResolutionSource::CompatEnv {
327 kind: ProviderKind::Anthropic,
328 model_id: model,
329 },
330 })
331}
332
333fn openai_from_env() -> anyhow::Result<ProviderLlm> {
334 let key = std::env::var("OPENAI_API_KEY")
335 .map_err(|_| anyhow::anyhow!("OPENAI_API_KEY not set (required for extraction)"))?;
336 let model = std::env::var("OPENAI_MODEL")
337 .or_else(|_| std::env::var("OXIBRAIN_MODEL"))
338 .unwrap_or_else(|_| "gpt-4o".to_string());
339 Ok(ProviderLlm {
340 port: Arc::new(oxibrain_llm_http::OpenAiLlm::new(key, model.clone())),
341 model_id: model.clone(),
342 mechanism: ExtractMechanism::JsonSchema,
343 model_digest: None,
344 tokenizer: None,
345 source: ResolutionSource::CompatEnv {
346 kind: ProviderKind::OpenAi,
347 model_id: model,
348 },
349 })
350}
351
352fn extract_entry(
354 entries: &[oxibrain::models::ModelEntry],
355) -> Option<&oxibrain::models::ModelEntry> {
356 entries
357 .iter()
358 .find(|e| e.role == oxibrain::models::ModelRole::Extract)
359}
360
361async fn ensure_local_model_present() -> anyhow::Result<()> {
365 use oxibrain::models::{default_manifest, load_manifest, model_dir, pull_entry, save_manifest};
366 use oxibrain::pull_plan::{ExtractPullPlan, plan_extract_pull};
367
368 let dir = model_dir();
369 std::fs::create_dir_all(&dir)?;
371 let manifest = load_manifest().map_err(|e| anyhow::anyhow!("load model manifest: {e}"))?;
374 let defaults = default_manifest();
375 let plan = plan_extract_pull(&manifest, &dir, &defaults);
376
377 let entry = match plan {
378 ExtractPullPlan::NoOp => return Ok(()),
379 ExtractPullPlan::NeedsPullFromManifest(e) => e,
380 ExtractPullPlan::NeedsBootstrap(e) => {
381 let mut next = manifest.clone();
384 if !next.iter().any(|m| m.name == e.name) {
385 next.push(e.clone());
386 save_manifest(&next)?;
387 }
388 e
389 }
390 };
391
392 println!(
393 "pulling local extract model {} ({} MiB) — first use only...",
394 entry.name, entry.size_mb
395 );
396 pull_entry(&entry, &dir, oxibrain::models::cli_progress)
397 .await
398 .map_err(|e| anyhow::anyhow!("pull {}: {e}", entry.name))?;
399 println!(" verified");
400 Ok(())
401}
402
403async fn local_from_manifest() -> anyhow::Result<ProviderLlm> {
408 use oxibrain::models::{load_manifest, model_dir, verify_entry};
409
410 ensure_local_model_present().await?;
411
412 let manifest = load_manifest().context("load model manifest")?;
413 let entry = extract_entry(&manifest)
414 .ok_or_else(|| anyhow::anyhow!("local extract model could not be resolved after pull"))?;
415 let dir = model_dir();
416 verify_entry(entry, &dir)
417 .map_err(|e| anyhow::anyhow!("model digest mismatch for {}: {e}", entry.name))?;
418 let path = dir.join(&entry.file);
419 let llm = Arc::new(
420 oxibrain_llm_local::LocalLlm::open(&path, oxibrain_llm_local::LocalLlmOptions::default())
421 .map_err(|e| anyhow::anyhow!("open local model {}: {e}", path.display()))?,
422 );
423 Ok(ProviderLlm {
424 model_id: entry.name.clone(),
425 mechanism: ExtractMechanism::Grammar,
426 model_digest: Some(entry.digest.clone()),
427 port: llm.clone(),
430 tokenizer: Some(llm),
431 source: ResolutionSource::Local,
432 })
433}
434
435pub fn config(
437 model_id: String,
438 mechanism: ExtractMechanism,
439 model_digest: Option<String>,
440 provider_profile_id: Option<String>,
441) -> oxibrain_core::extraction::ExtractorConfig {
442 use oxibrain_core::registry::CORE_V1_MAJOR;
443 oxibrain_core::extraction::ExtractorConfig {
444 model_id,
445 prompt_version: 2, registry_major: CORE_V1_MAJOR,
447 mechanism,
448 max_tokens: 8192,
449 model_digest,
450 provider_profile_id,
451 }
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
464
465 #[test]
466 fn explicit_provider_wins() {
467 assert_eq!(
468 resolve_provider(Some("local"), true, true).unwrap(),
469 Provider::Local
470 );
471 assert_eq!(
472 resolve_provider(Some("openai"), true, false).unwrap(),
473 Provider::OpenAi
474 );
475 assert_eq!(
476 resolve_provider(Some("anthropic"), false, false).unwrap(),
477 Provider::Anthropic
478 );
479 }
480
481 #[test]
482 fn unknown_provider_is_rejected() {
483 assert!(resolve_provider(Some("gemini"), false, false).is_err());
484 }
485
486 #[test]
487 fn no_explicit_and_no_key_falls_back_to_local() {
488 assert_eq!(
490 resolve_provider(None, false, false).unwrap(),
491 Provider::Local
492 );
493 }
494
495 #[test]
496 fn anthropic_key_preferred_over_local() {
497 assert_eq!(
498 resolve_provider(None, true, false).unwrap(),
499 Provider::Anthropic
500 );
501 assert_eq!(
502 resolve_provider(None, false, true).unwrap(),
503 Provider::OpenAi
504 );
505 }
506
507 #[test]
508 fn resolve_role_defaults_to_memory_extract() {
509 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
510 let saved = std::env::var_os("OXIBRAIN_LLM_ROLE");
511 unsafe {
513 std::env::remove_var("OXIBRAIN_LLM_ROLE");
514 }
515 let got = resolve_role();
516 unsafe {
518 if let Some(v) = saved {
519 std::env::set_var("OXIBRAIN_LLM_ROLE", v);
520 }
521 }
522 assert_eq!(got, ProfileRole::MemoryExtract);
523 }
524
525 #[test]
526 fn resolve_role_honours_env_when_recognised() {
527 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
528 let saved = std::env::var_os("OXIBRAIN_LLM_ROLE");
529 unsafe {
531 std::env::set_var("OXIBRAIN_LLM_ROLE", "coding.primary");
532 }
533 let got = resolve_role();
534 unsafe {
536 match saved {
537 Some(v) => std::env::set_var("OXIBRAIN_LLM_ROLE", v),
538 None => std::env::remove_var("OXIBRAIN_LLM_ROLE"),
539 }
540 }
541 assert_eq!(got, ProfileRole::CodingPrimary);
542 }
543
544 #[test]
545 fn extract_role_entry_is_selected() {
546 use oxibrain::models::{ModelEntry, ModelRole};
547 let mk = |role: ModelRole, name: &str| ModelEntry {
548 role,
549 name: name.into(),
550 url: String::new(),
551 digest: format!("d-{name}"),
552 size_mb: 1,
553 license: String::new(),
554 file: format!("{name}.gguf"),
555 };
556 let entries = vec![
557 mk(ModelRole::Embed, "bge-m3"),
558 mk(ModelRole::Extract, "qwen2.5-1.5b-instruct"),
559 ];
560 let got = extract_entry(&entries).expect("extract entry");
561 assert_eq!(got.name, "qwen2.5-1.5b-instruct");
562 assert_eq!(got.digest, "d-qwen2.5-1.5b-instruct");
563 }
564}