Skip to main content

oxibrain_cli/cmd/
llm.rs

1//! Shared LLM provider construction from environment variables.
2//!
3//! Used by `extract` and `reextract`. Providers:
4//!   - `OXIBRAIN_LLM_PROVIDER=anthropic` (+ `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`)
5//!   - `OXIBRAIN_LLM_PROVIDER=openai`     (+ `OPENAI_API_KEY`, `OPENAI_MODEL`)
6//!   - `OXIBRAIN_LLM_PROVIDER=local`      (GGUF from `oxibrain model pull`, §8.4)
7//!
8//! Resolution order for [`from_env_for_role`], the role-aware entry point
9//! (Oxi Foundation v1, Task 3 §3):
10//!
11//!   1. Explicit `OXIBRAIN_LLM_PROVIDER` (CLI / automation override).
12//!   2. Foundation profile for the requested role whose declared
13//!      capabilities satisfy the configured extraction mechanism, and whose
14//!      Keychain secret resolves. A missing/unavailable secret reports why
15//!      that profile cannot run and falls through to (3). It never silently
16//!      sends extraction to a different remote provider.
17//!   3. Existing `ANTHROPIC_*` / `OPENAI_*` compatibility environment.
18//!   4. Local GGUF (C2 — no API key required, default).
19//!
20//! The legacy [`from_env`] / [`resolve_provider`] entry points remain in
21//! place so the existing `extract` / `reextract` callers do not move; they
22//! default to role `memory.extract`. `OXIBRAIN_LLM_ROLE` overrides the role
23//! when present.
24//!
25//! `OXIBRAIN_MODEL` is a fallback for the HTTP model id. The mechanism
26//! (tool-call / json-schema / GBNF grammar) follows the provider — Anthropic
27//! uses forced tool calls, OpenAI native json_schema structured output, and
28//! the local path grammar-constrained decoding (DESIGN §7.4, §9.4).
29
30use 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/// Which provider `from_env` resolved. Testable without touching the network
41/// or loading model weights.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Provider {
44    Anthropic,
45    OpenAi,
46    Local,
47}
48
49/// Where the resolved provider came from. Carries enough metadata for callers
50/// (and tests) to prove the resolution ladder actually fired the step they
51/// expect. Foundation-resolved profiles additionally surface the profile id
52/// so Task 5 can plumb it into `ExtractorConfig` / `ExtractorId` provenance.
53#[derive(Debug, Clone)]
54// Variant fields and the `ExplicitOverride` variant are part of the public
55// resolution-ladder API; some are constructed for future callers / pattern
56// matches without binding their fields, which trips cargo's `dead_code` lint
57// from inside the `oxibrain-cli` crate. The allow keeps the API surface
58// unencumbered; the lint still fires for genuinely unused code below.
59#[allow(dead_code)]
60pub enum ResolutionSource {
61    /// Explicit `OXIBRAIN_LLM_PROVIDER=…` override.
62    ExplicitOverride(Provider),
63    /// A Foundation profile for the requested role, with the secret resolved
64    /// out-of-band. `profile_id` is the profile's `id` field; `model_id` is
65    /// the profile's `model`; `provider` and `mechanism` are derived from the
66    /// profile's `provider` field and the host's adapter catalogue.
67    FoundationProfile {
68        profile_id: String,
69        provider: ProviderKind,
70        model_id: String,
71        mechanism: ExtractMechanism,
72    },
73    /// Existing compatibility environment variable. `kind` is `Anthropic` or
74    /// `OpenAi`; the model id is whatever `*_MODEL` / `OXIBRAIN_MODEL`
75    /// resolved to.
76    CompatEnv {
77        kind: ProviderKind,
78        model_id: String,
79    },
80    /// Local GGUF — the standalone default (C2).
81    Local,
82}
83
84/// A resolved LLM provider: the port plus everything `ExtractorConfig` and
85/// `Brain` need to reflect it (model id, mechanism, weights digest, exact
86/// tokenizer when the provider ships one, plus the resolution source for
87/// provenance).
88pub struct ProviderLlm {
89    pub port: Arc<dyn LlmPort>,
90    pub model_id: String,
91    pub mechanism: ExtractMechanism,
92    /// blake3 hex digest of the weights, when the provider is a local artifact
93    /// (§9.5 — weight changes must invalidate the extraction cache).
94    pub model_digest: Option<String>,
95    pub tokenizer: Option<Arc<dyn TokenizerPort>>,
96    /// Where the resolution ladder picked this provider. Task 5 threads
97    /// profile identity / model digest into `ExtractorId` provenance from
98    /// this field; the CLI does not edit `ExtractorConfig` directly here.
99    pub source: ResolutionSource,
100}
101
102impl ProviderLlm {
103    /// Foundation profile id when the provider came from a profile
104    /// resolution; `None` for the legacy compat env / explicit override /
105    /// local GGUF paths. Consumed by Task 5 to fold the binding into
106    /// `ExtractorConfig::provider_profile_id` and invalidate cached
107    /// summaries when the role changes (§13).
108    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
118/// Decide the provider from the explicit override alone, without consulting
119/// Foundation profiles. `key_present` / `openai_key_present` are injected so
120/// tests stay hermetic. Kept for the legacy callers; new code should prefer
121/// [`from_env_for_role`].
122pub 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        // No explicit choice: prefer a configured HTTP provider, fall back to
135        // the local model so the no-API-key promise holds.
136        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
142/// Role chosen by `OXIBRAIN_LLM_ROLE` (or the default). The env var is the
143/// only way to override the role today; future revisions can extend
144/// `OXIBRAIN_LLM_ROLE` to comma-separated lists for fan-out consolidation.
145pub 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        // An unparseable role is loud — extraction must not silently fall to
151        // a different role. The caller treats this as a Foundation parse
152        // rejection when it eventually surfaces.
153        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
161/// Build an LLM port from the environment using the legacy (role-less)
162/// ladder. Preserved for existing `extract` / `reextract` callers that have
163/// not yet opted into the Foundation-aware entry point.
164pub async fn from_env() -> anyhow::Result<ProviderLlm> {
165    from_env_for_role(resolve_role()).await
166}
167
168/// Build an LLM port from the environment for a specific role.
169///
170/// Walks the resolution ladder documented at the top of this module. A
171/// missing/unavailable Foundation secret is reported to stderr and falls
172/// through to the next step — never silently to a different remote provider.
173pub 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    // Step 1 — explicit override always wins (automation / dev override).
179    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    // Step 2 — Foundation profile for the requested role. `secret_resolver`
188    // is the production default unless the caller passes its own.
189    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    // Step 3 — ANTHROPIC_* / OPENAI_* compat env.
200    if anthropic_key_present {
201        return anthropic_from_env();
202    }
203    if openai_key_present {
204        return openai_from_env();
205    }
206
207    // Step 4 — local (C2).
208    local_from_manifest().await
209}
210
211/// Attempt to resolve a Foundation profile for the role. Returns:
212///   - `Ok(Some(_))` when a profile was selected and its secret resolved.
213///   - `Ok(None)` when the resolver reported `SecretUnavailable` for the
214///     only candidate profile; the caller falls through to the next ladder
215///     step after logging the reason. This is the explicit "do not silently
216///     send to a different remote provider" guarantee.
217///   - `Err(_)` for hard parse / capability rejections that should surface to
218///     the operator.
219#[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    // Pick the configured mechanism per provider so a truthful OpenAI profile
226    // that declares only `json_schema: true` is accepted, not bailed with
227    // CapabilityUnsatisfied against ToolCall. We iterate the profiles and
228    // try each one with its native mechanism so a single profile list can
229    // carry heterogeneous declarations.
230    //
231    // Algorithm:
232    //   1. For each profile that lists `role`, determine its native mechanism
233    //      from its `provider` field.
234    //   2. Validate against that mechanism. Reject loudly if declared
235    //      capabilities don't satisfy it.
236    //   3. Return the first profile that survives capability validation.
237    //
238    // When no profile declares the role we fall through silently (compat env
239    // / local may still satisfy the request).
240    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    // Resolve the secret out-of-band. A missing secret here falls through to
269    // compat env / local; we never send extraction to a different remote
270    // provider.
271    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
352/// Pick the extract-role entry out of a manifest. Pure, for tests.
353fn 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
361/// Make sure the local extract model is on disk before we open it. Pure
362/// decision in `oxibrain::pull_plan`; the pull (network, fs writes) lives
363/// here where it can show progress to a real terminal.
364async 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    // Touch the dir so plan_extract_pull can find files there.
370    std::fs::create_dir_all(&dir)?;
371    // A malformed manifest is a loud error, not a silent reset: bootstrap
372    // must never overwrite entries the user cannot see were dropped.
373    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            // First-time setup: persist the default manifest so subsequent
382            // loads are stable.
383            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
403/// Load the local extraction model from the artifact manifest (§8.4): verify
404/// the digest (weight changes must change the ExtractorId, §9.5), open the
405/// GGUF, and expose its tokenizer (§7.5). Lazy-pulls the model on first use
406/// so `oxibrain init` does not have to download anything.
407async 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        // LocalLlm implements both ports — same weights, exact token counts
428        // (§7.5: counted, never estimated).
429        port: llm.clone(),
430        tokenizer: Some(llm),
431        source: ResolutionSource::Local,
432    })
433}
434
435/// Build a default extractor config from the env-resolved model + mechanism.
436pub 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, // v2: quote-based mentions (ADR-006)
446        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    /// Process-wide lock for tests that mutate `OXIBRAIN_LLM_ROLE`.
459    /// cargo defaults to running tests in parallel across threads; env
460    /// vars are process-global, so any two tests that touch the same
461    /// variable race. Every set-var / remove-var call in this module
462    /// MUST hold this lock for the duration of the test body.
463    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        // C2: extraction must work with no API key.
489        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        // SAFETY: env vars are serialised via ENV_LOCK in this module.
512        unsafe {
513            std::env::remove_var("OXIBRAIN_LLM_ROLE");
514        }
515        let got = resolve_role();
516        // SAFETY: see above.
517        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        // SAFETY: env vars are serialised via ENV_LOCK in this module.
530        unsafe {
531            std::env::set_var("OXIBRAIN_LLM_ROLE", "coding.primary");
532        }
533        let got = resolve_role();
534        // SAFETY: see above.
535        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}