Skip to main content

gobby_core/
ai_context.rs

1//! Shared AI context, layered AI config source, and config-only routing.
2//!
3//! This module intentionally stays transport-free. It resolves the desired AI
4//! bindings and routing from caller-provided config layers, then leaves any
5//! probe-backed routing collapse to feature-gated transport code.
6
7use std::sync::{Arc, Condvar, Mutex};
8
9use crate::config::{
10    AiCapability, AiRouting, AiTuning, CapabilityBinding, ConfigSource, resolve_ai_tuning,
11    resolve_capability_binding,
12};
13use crate::provisioning::{StandaloneConfig, gcore_config_path};
14
15const ALL_CAPABILITIES: [AiCapability; 5] = [
16    AiCapability::Embed,
17    AiCapability::AudioTranscribe,
18    AiCapability::AudioTranslate,
19    AiCapability::VisionExtract,
20    AiCapability::TextGenerate,
21];
22
23/// Resolved AI context shared by gcore consumers.
24#[derive(Debug, Clone)]
25pub struct AiContext {
26    pub bindings: AiBindings,
27    pub tuning: AiTuning,
28    pub limiter: AiLimiter,
29    pub project_id: Option<String>,
30}
31
32impl AiContext {
33    /// Resolve AI context from a caller-supplied project authority and source.
34    pub fn resolve(project_id: Option<String>, source: &mut impl ConfigSource) -> Self {
35        Self::resolve_with_options(project_id, source, AiContextOptions::default())
36    }
37
38    /// Resolve AI context with command-scoped routing overrides.
39    pub fn resolve_with_options(
40        project_id: Option<String>,
41        source: &mut impl ConfigSource,
42        options: AiContextOptions,
43    ) -> Self {
44        let mut bindings = AiBindings::resolve(source);
45        let mut tuning = resolve_ai_tuning(source);
46
47        if options.no_ai {
48            bindings.force_routing(AiRouting::Off);
49        } else if let Some(routing) = options.forced_routing {
50            bindings.force_routing(routing);
51        }
52
53        if tuning.max_concurrency == 0 {
54            tuning.max_concurrency = 1;
55        }
56        let limiter = AiLimiter::new(tuning.max_concurrency);
57
58        Self {
59            bindings,
60            tuning,
61            limiter,
62            project_id,
63        }
64    }
65
66    pub fn binding(&self, capability: AiCapability) -> &CapabilityBinding {
67        self.bindings.get(capability)
68    }
69}
70
71/// Command-scoped AI context overrides.
72#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub struct AiContextOptions {
74    pub no_ai: bool,
75    pub forced_routing: Option<AiRouting>,
76}
77
78/// Per-capability AI bindings.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct AiBindings {
81    pub embed: CapabilityBinding,
82    pub audio_transcribe: CapabilityBinding,
83    pub audio_translate: CapabilityBinding,
84    pub vision_extract: CapabilityBinding,
85    pub text_generate: CapabilityBinding,
86}
87
88impl AiBindings {
89    pub fn resolve(source: &mut impl ConfigSource) -> Self {
90        Self {
91            embed: resolve_capability_binding(source, AiCapability::Embed),
92            audio_transcribe: resolve_capability_binding(source, AiCapability::AudioTranscribe),
93            audio_translate: resolve_capability_binding(source, AiCapability::AudioTranslate),
94            vision_extract: resolve_capability_binding(source, AiCapability::VisionExtract),
95            text_generate: resolve_capability_binding(source, AiCapability::TextGenerate),
96        }
97    }
98
99    pub fn get(&self, capability: AiCapability) -> &CapabilityBinding {
100        match capability {
101            AiCapability::Embed => &self.embed,
102            AiCapability::AudioTranscribe => &self.audio_transcribe,
103            AiCapability::AudioTranslate => &self.audio_translate,
104            AiCapability::VisionExtract => &self.vision_extract,
105            AiCapability::TextGenerate => &self.text_generate,
106        }
107    }
108
109    fn get_mut(&mut self, capability: AiCapability) -> &mut CapabilityBinding {
110        match capability {
111            AiCapability::Embed => &mut self.embed,
112            AiCapability::AudioTranscribe => &mut self.audio_transcribe,
113            AiCapability::AudioTranslate => &mut self.audio_translate,
114            AiCapability::VisionExtract => &mut self.vision_extract,
115            AiCapability::TextGenerate => &mut self.text_generate,
116        }
117    }
118
119    fn force_routing(&mut self, routing: AiRouting) {
120        for capability in ALL_CAPABILITIES {
121            self.get_mut(capability).routing = routing;
122        }
123    }
124}
125
126#[cfg(test)]
127const LOCAL_BACKEND_CAPABILITIES: [AiCapability; 3] = [
128    AiCapability::Embed,
129    AiCapability::VisionExtract,
130    AiCapability::TextGenerate,
131];
132
133#[cfg(test)]
134pub(crate) fn apply_discovered_local_backend(
135    bindings: &mut AiBindings,
136    backend: &crate::local_backend::Backend,
137) {
138    let api_base = crate::local_backend::backend_api_base(backend);
139    for capability in LOCAL_BACKEND_CAPABILITIES {
140        let binding = bindings.get_mut(capability);
141        if binding_needs_local_api_base(binding) {
142            binding.api_base = Some(api_base.clone());
143        }
144    }
145}
146
147#[cfg(test)]
148fn binding_needs_local_api_base(binding: &CapabilityBinding) -> bool {
149    matches!(binding.routing, AiRouting::Auto | AiRouting::Direct)
150        && binding
151            .api_base
152            .as_deref()
153            .map(str::trim)
154            .is_none_or(str::is_empty)
155}
156
157/// Return the config-only desired route for a capability.
158pub fn route(context: &AiContext, capability: AiCapability) -> AiRouting {
159    context.binding(capability).routing
160}
161
162/// Shared blocking concurrency limiter for AI transports.
163#[derive(Clone)]
164pub struct AiLimiter {
165    inner: Arc<LimiterInner>,
166}
167
168struct LimiterInner {
169    max: u8,
170    active: Mutex<u8>,
171    available: Condvar,
172}
173
174impl AiLimiter {
175    pub fn new(max_concurrency: u8) -> Self {
176        Self {
177            inner: Arc::new(LimiterInner {
178                max: max_concurrency.max(1),
179                active: Mutex::new(0),
180                available: Condvar::new(),
181            }),
182        }
183    }
184
185    pub fn max_concurrency(&self) -> u8 {
186        self.inner.max
187    }
188
189    pub fn acquire(&self) -> AiPermit {
190        let mut active = self
191            .inner
192            .active
193            .lock()
194            .unwrap_or_else(|poisoned| poisoned.into_inner());
195        while *active >= self.inner.max {
196            active = self
197                .inner
198                .available
199                .wait(active)
200                .unwrap_or_else(|poisoned| poisoned.into_inner());
201        }
202        *active += 1;
203        AiPermit {
204            inner: Arc::clone(&self.inner),
205        }
206    }
207
208    pub fn try_acquire(&self) -> Option<AiPermit> {
209        let mut active = self
210            .inner
211            .active
212            .lock()
213            .unwrap_or_else(|poisoned| poisoned.into_inner());
214        if *active >= self.inner.max {
215            return None;
216        }
217        *active += 1;
218        Some(AiPermit {
219            inner: Arc::clone(&self.inner),
220        })
221    }
222}
223
224impl std::fmt::Debug for AiLimiter {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("AiLimiter")
227            .field("max_concurrency", &self.max_concurrency())
228            .finish_non_exhaustive()
229    }
230}
231
232/// Permit returned by [`AiLimiter`].
233#[derive(Debug)]
234pub struct AiPermit {
235    inner: Arc<LimiterInner>,
236}
237
238impl Drop for AiPermit {
239    fn drop(&mut self) {
240        let mut active = self
241            .inner
242            .active
243            .lock()
244            .unwrap_or_else(|poisoned| poisoned.into_inner());
245        *active = active.saturating_sub(1);
246        self.inner.available.notify_one();
247    }
248}
249
250impl std::fmt::Debug for LimiterInner {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        f.debug_struct("LimiterInner")
253            .field("max", &self.max)
254            .finish_non_exhaustive()
255    }
256}
257
258/// Layered AI config source: primary config_store-like source, then gcore.yaml.
259///
260/// The source is intentionally AI-only: plain values are returned literally and
261/// `$secret:` values are delegated to the primary source when present.
262#[derive(Debug, Clone)]
263pub struct AiConfigSource<P = NoPrimaryAiConfigSource> {
264    primary: Option<P>,
265    standalone: Option<StandaloneConfig>,
266}
267
268pub type LocalAiConfigSource = AiConfigSource<NoPrimaryAiConfigSource>;
269
270impl LocalAiConfigSource {
271    pub fn from_gobby_home(gobby_home: &std::path::Path) -> anyhow::Result<Self> {
272        Ok(Self::with_primary(
273            NoPrimaryAiConfigSource,
274            StandaloneConfig::read_at(&gcore_config_path(gobby_home))?,
275        ))
276    }
277}
278
279impl<P> AiConfigSource<P>
280where
281    P: ConfigSource,
282{
283    pub fn with_primary(primary: P, standalone: Option<StandaloneConfig>) -> Self {
284        Self {
285            primary: Some(primary),
286            standalone,
287        }
288    }
289
290    pub fn with_primary_from_gobby_home(
291        primary: P,
292        gobby_home: &std::path::Path,
293    ) -> anyhow::Result<Self> {
294        Ok(Self::with_primary(
295            primary,
296            StandaloneConfig::read_at(&gcore_config_path(gobby_home))?,
297        ))
298    }
299}
300
301impl<P> ConfigSource for AiConfigSource<P>
302where
303    P: ConfigSource,
304{
305    fn config_value(&mut self, key: &str) -> Option<String> {
306        self.primary
307            .as_mut()
308            .and_then(|source| source.config_value(key))
309            .or_else(|| {
310                self.standalone
311                    .as_mut()
312                    .and_then(|standalone| standalone.config_value(key))
313            })
314    }
315
316    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
317        if value.trim().starts_with("$secret:") {
318            let Some(primary) = self.primary.as_mut() else {
319                anyhow::bail!("secret resolution requires a daemon-backed AI config source");
320            };
321            return primary.resolve_value(value);
322        }
323        match self.standalone.as_mut() {
324            Some(standalone) => standalone.resolve_value(value),
325            None => Ok(value.to_string()),
326        }
327    }
328}
329
330/// Empty primary layer for local-only `gcore.yaml` resolution.
331#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
332pub struct NoPrimaryAiConfigSource;
333
334impl ConfigSource for NoPrimaryAiConfigSource {
335    fn config_value(&mut self, _key: &str) -> Option<String> {
336        None
337    }
338
339    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
340        if value.trim().starts_with("$secret:") {
341            anyhow::bail!("secret resolution requires a daemon-backed AI config source");
342        }
343        Ok(value.to_string())
344    }
345}
346
347/// PostgreSQL config_store source for AI config.
348#[cfg(feature = "postgres")]
349pub struct PostgresAiConfigSource<'a, R> {
350    conn: &'a mut postgres::Client,
351    resolver: R,
352    config_store_available: bool,
353}
354
355#[cfg(feature = "postgres")]
356impl<'a, R> PostgresAiConfigSource<'a, R>
357where
358    R: FnMut(&str, &mut postgres::Client) -> anyhow::Result<String>,
359{
360    pub fn new(conn: &'a mut postgres::Client, resolver: R) -> Self {
361        Self {
362            conn,
363            resolver,
364            config_store_available: true,
365        }
366    }
367
368    pub fn config_store_available(&self) -> bool {
369        self.config_store_available
370    }
371}
372
373#[cfg(feature = "postgres")]
374impl<R> ConfigSource for PostgresAiConfigSource<'_, R>
375where
376    R: FnMut(&str, &mut postgres::Client) -> anyhow::Result<String>,
377{
378    fn config_value(&mut self, key: &str) -> Option<String> {
379        if !self.config_store_available {
380            return None;
381        }
382        match crate::postgres::read_config_value(self.conn, key) {
383            Ok(raw) => raw.and_then(|raw| crate::config::decode_config_value(&raw)),
384            Err(error) if config_store_missing(&error) => {
385                self.config_store_available = false;
386                None
387            }
388            Err(error) => {
389                log::warn!("failed to read AI config key {key:?}: {error}");
390                None
391            }
392        }
393    }
394
395    fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
396        if value.trim().starts_with("$secret:") {
397            return (self.resolver)(value, self.conn);
398        }
399        Ok(value.to_string())
400    }
401}
402
403#[cfg(feature = "postgres")]
404fn config_store_missing(error: &anyhow::Error) -> bool {
405    error.chain().any(|source| {
406        source
407            .downcast_ref::<postgres::Error>()
408            .and_then(postgres::Error::as_db_error)
409            .is_some_and(|db_error| *db_error.code() == postgres::error::SqlState::UNDEFINED_TABLE)
410    })
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::config::{AiCapability, AiRouting, ConfigSource, ai_keys};
417    use crate::provisioning::gcore_config_path;
418    use std::collections::HashMap;
419    use std::fs;
420    use std::path::PathBuf;
421    use std::sync::{Mutex, MutexGuard};
422
423    static CWD_LOCK: Mutex<()> = Mutex::new(());
424
425    struct TestSource {
426        values: HashMap<&'static str, String>,
427        resolved: HashMap<&'static str, String>,
428    }
429
430    impl TestSource {
431        fn with_values(values: impl IntoIterator<Item = (&'static str, &'static str)>) -> Self {
432            Self {
433                values: values
434                    .into_iter()
435                    .map(|(key, value)| (key, value.to_string()))
436                    .collect(),
437                resolved: HashMap::new(),
438            }
439        }
440
441        fn with_resolved(
442            mut self,
443            values: impl IntoIterator<Item = (&'static str, &'static str)>,
444        ) -> Self {
445            self.resolved = values
446                .into_iter()
447                .map(|(key, value)| (key, value.to_string()))
448                .collect();
449            self
450        }
451    }
452
453    impl ConfigSource for TestSource {
454        fn config_value(&mut self, key: &str) -> Option<String> {
455            self.values.get(key).cloned()
456        }
457
458        fn resolve_value(&mut self, value: &str) -> anyhow::Result<String> {
459            self.resolved
460                .get(value)
461                .cloned()
462                .ok_or_else(|| anyhow::anyhow!("unresolved test value: {value}"))
463        }
464    }
465
466    struct CurrentDirGuard {
467        _lock: MutexGuard<'static, ()>,
468        original: PathBuf,
469    }
470
471    impl CurrentDirGuard {
472        fn set(path: &std::path::Path) -> Self {
473            let guard = CWD_LOCK
474                .lock()
475                .unwrap_or_else(|poisoned| poisoned.into_inner());
476            let original = std::env::current_dir().expect("current dir");
477            std::env::set_current_dir(path).expect("set current dir");
478            Self {
479                _lock: guard,
480                original,
481            }
482        }
483    }
484
485    impl Drop for CurrentDirGuard {
486        fn drop(&mut self) {
487            std::env::set_current_dir(&self.original).expect("restore current dir");
488        }
489    }
490
491    fn write_gcore_yaml(home: &std::path::Path, contents: &str) {
492        let path = gcore_config_path(home);
493        fs::create_dir_all(path.parent().expect("gcore config parent")).unwrap();
494        fs::write(path, contents).unwrap();
495    }
496
497    fn binding(routing: AiRouting, api_base: Option<&str>) -> CapabilityBinding {
498        CapabilityBinding {
499            routing,
500            transport: None,
501            api_base: api_base.map(str::to_string),
502            api_key: None,
503            model: None,
504            provider: None,
505            task: None,
506            language: None,
507            target_lang: None,
508        }
509    }
510
511    #[test]
512    fn resolves_in_db_and_no_db_modes() {
513        let home = tempfile::tempdir().unwrap();
514        write_gcore_yaml(
515            home.path(),
516            r#"
517ai:
518  embeddings:
519    api_base: http://yaml-embedding
520    model: yaml-embedding-model
521    api_key: yaml-key
522  audio_transcribe:
523    routing: direct
524  max_concurrency: 3
525"#,
526        );
527
528        let mut no_db = LocalAiConfigSource::from_gobby_home(home.path()).unwrap();
529        let no_db_context = AiContext::resolve(Some("yaml-project".to_string()), &mut no_db);
530
531        let no_db_embed = no_db_context.binding(AiCapability::Embed);
532        assert_eq!(
533            no_db_embed.api_base.as_deref(),
534            Some("http://yaml-embedding")
535        );
536        assert_eq!(no_db_embed.model.as_deref(), Some("yaml-embedding-model"));
537        assert_eq!(no_db_embed.api_key.as_deref(), Some("yaml-key"));
538        assert_eq!(
539            route(&no_db_context, AiCapability::AudioTranscribe),
540            AiRouting::Direct
541        );
542        assert_eq!(no_db_context.tuning.max_concurrency, 3);
543        assert_eq!(no_db_context.limiter.max_concurrency(), 3);
544        assert_eq!(no_db_context.project_id.as_deref(), Some("yaml-project"));
545
546        let primary = TestSource::with_values([
547            (ai_keys::EMBEDDINGS_API_BASE, "http://db-embedding"),
548            (ai_keys::EMBEDDINGS_API_KEY, "$secret:db-embedding-key"),
549            (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "daemon"),
550            (ai_keys::MAX_CONCURRENCY, "2"),
551        ])
552        .with_resolved([("$secret:db-embedding-key", "resolved-db-key")]);
553        let mut db = AiConfigSource::with_primary_from_gobby_home(primary, home.path()).unwrap();
554        let db_context = AiContext::resolve(Some("db-project".to_string()), &mut db);
555
556        let db_embed = db_context.binding(AiCapability::Embed);
557        assert_eq!(db_embed.api_base.as_deref(), Some("http://db-embedding"));
558        assert_eq!(db_embed.model.as_deref(), Some("yaml-embedding-model"));
559        assert_eq!(db_embed.api_key.as_deref(), Some("resolved-db-key"));
560        assert_eq!(
561            route(&db_context, AiCapability::AudioTranscribe),
562            AiRouting::Daemon
563        );
564        assert_eq!(db_context.tuning.max_concurrency, 2);
565    }
566
567    #[test]
568    fn project_id_is_caller_supplied() {
569        let home = tempfile::tempdir().unwrap();
570        write_gcore_yaml(home.path(), "ai:\n  routing: direct\n");
571        let cwd = tempfile::tempdir().unwrap();
572        fs::create_dir_all(cwd.path().join(".gobby")).unwrap();
573        fs::write(
574            cwd.path().join(".gobby/project.json"),
575            r#"{"id":"stray-cwd-project"}"#,
576        )
577        .unwrap();
578        let _cwd = CurrentDirGuard::set(cwd.path());
579
580        let mut topic_source = LocalAiConfigSource::from_gobby_home(home.path()).unwrap();
581        let topic_context = AiContext::resolve(None, &mut topic_source);
582        assert_eq!(topic_context.project_id, None);
583
584        let mut project_source = LocalAiConfigSource::from_gobby_home(home.path()).unwrap();
585        let project_context =
586            AiContext::resolve(Some("scope-project".to_string()), &mut project_source);
587        assert_eq!(project_context.project_id.as_deref(), Some("scope-project"));
588    }
589
590    #[test]
591    fn db_without_config_store_falls_through() {
592        let home = tempfile::tempdir().unwrap();
593        write_gcore_yaml(
594            home.path(),
595            r#"
596ai:
597  text_generate:
598    routing: direct
599    api_base: http://yaml-text
600"#,
601        );
602        let primary = TestSource::with_values([]);
603        let mut source =
604            AiConfigSource::with_primary_from_gobby_home(primary, home.path()).unwrap();
605
606        let context = AiContext::resolve(None, &mut source);
607
608        assert_eq!(
609            route(&context, AiCapability::TextGenerate),
610            AiRouting::Direct
611        );
612        assert_eq!(
613            context
614                .binding(AiCapability::TextGenerate)
615                .api_base
616                .as_deref(),
617            Some("http://yaml-text")
618        );
619    }
620
621    #[test]
622    fn standalone_values_expand_env_patterns_for_db_fallback() {
623        let home = tempfile::tempdir().unwrap();
624        write_gcore_yaml(
625            home.path(),
626            r#"
627ai:
628  text_generate:
629    routing: direct
630    api_base: ${GOBBY_AI_CONTEXT_TEST_MISSING:-http://expanded-text}
631"#,
632        );
633        let primary = TestSource::with_values([]);
634        let mut source =
635            AiConfigSource::with_primary_from_gobby_home(primary, home.path()).unwrap();
636
637        let context = AiContext::resolve(None, &mut source);
638
639        assert_eq!(
640            context
641                .binding(AiCapability::TextGenerate)
642                .api_base
643                .as_deref(),
644            Some("http://expanded-text")
645        );
646    }
647
648    #[test]
649    fn concurrency_cap_enforced() {
650        let limiter = AiLimiter::new(1);
651        let permit = limiter
652            .try_acquire()
653            .expect("first permit should be available");
654
655        assert!(limiter.try_acquire().is_none());
656
657        drop(permit);
658
659        assert!(limiter.try_acquire().is_some());
660    }
661
662    #[test]
663    fn forced_routing_and_no_ai_override() {
664        let source = TestSource::with_values([
665            (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "daemon"),
666            (ai_keys::VISION_EXTRACT_ROUTING, "direct"),
667        ]);
668        let mut source = AiConfigSource::with_primary(source, None);
669        let context = AiContext::resolve(None, &mut source);
670        assert_eq!(
671            route(&context, AiCapability::AudioTranscribe),
672            AiRouting::Daemon
673        );
674        assert_eq!(
675            route(&context, AiCapability::VisionExtract),
676            AiRouting::Direct
677        );
678        assert_eq!(route(&context, AiCapability::Embed), AiRouting::Auto);
679
680        let source = TestSource::with_values([
681            (ai_keys::AUDIO_TRANSCRIBE_ROUTING, "daemon"),
682            (ai_keys::VISION_EXTRACT_ROUTING, "off"),
683        ]);
684        let mut source = AiConfigSource::with_primary(source, None);
685        let forced = AiContext::resolve_with_options(
686            None,
687            &mut source,
688            AiContextOptions {
689                forced_routing: Some(AiRouting::Direct),
690                ..AiContextOptions::default()
691            },
692        );
693        for capability in [
694            AiCapability::Embed,
695            AiCapability::AudioTranscribe,
696            AiCapability::AudioTranslate,
697            AiCapability::VisionExtract,
698            AiCapability::TextGenerate,
699        ] {
700            assert_eq!(route(&forced, capability), AiRouting::Direct);
701        }
702
703        let source = TestSource::with_values([(ai_keys::AUDIO_TRANSCRIBE_ROUTING, "daemon")]);
704        let mut source = AiConfigSource::with_primary(source, None);
705        let disabled = AiContext::resolve_with_options(
706            None,
707            &mut source,
708            AiContextOptions {
709                no_ai: true,
710                forced_routing: Some(AiRouting::Direct),
711            },
712        );
713        for capability in [
714            AiCapability::Embed,
715            AiCapability::AudioTranscribe,
716            AiCapability::AudioTranslate,
717            AiCapability::VisionExtract,
718            AiCapability::TextGenerate,
719        ] {
720            assert_eq!(route(&disabled, capability), AiRouting::Off);
721        }
722    }
723
724    #[test]
725    fn resolve_does_not_discover_local_backend_endpoints() {
726        let source = TestSource::with_values([
727            (ai_keys::EMBEDDINGS_ROUTING, "auto"),
728            (ai_keys::VISION_EXTRACT_ROUTING, "direct"),
729            (ai_keys::TEXT_GENERATE_ROUTING, "direct"),
730        ]);
731        let mut source = AiConfigSource::with_primary(source, None);
732
733        let context = AiContext::resolve(None, &mut source);
734
735        assert_eq!(route(&context, AiCapability::Embed), AiRouting::Auto);
736        assert_eq!(
737            route(&context, AiCapability::VisionExtract),
738            AiRouting::Direct
739        );
740        assert_eq!(
741            route(&context, AiCapability::TextGenerate),
742            AiRouting::Direct
743        );
744        assert_eq!(context.binding(AiCapability::Embed).api_base, None);
745        assert_eq!(context.binding(AiCapability::VisionExtract).api_base, None);
746        assert_eq!(context.binding(AiCapability::TextGenerate).api_base, None);
747    }
748
749    #[test]
750    fn stt_not_autodiscovered_to_chat_backend() {
751        let mut bindings = AiBindings {
752            embed: binding(AiRouting::Auto, None),
753            audio_transcribe: binding(AiRouting::Auto, None),
754            audio_translate: binding(AiRouting::Direct, None),
755            vision_extract: binding(AiRouting::Direct, None),
756            text_generate: binding(AiRouting::Auto, None),
757        };
758        let backend = crate::local_backend::Backend {
759            name: "lmstudio".into(),
760            url: "http://localhost:1234".into(),
761            probe: "/v1/models".into(),
762            auth_token: "lmstudio".into(),
763        };
764
765        apply_discovered_local_backend(&mut bindings, &backend);
766
767        assert_eq!(
768            bindings.embed.api_base.as_deref(),
769            Some("http://localhost:1234/v1")
770        );
771        assert_eq!(
772            bindings.vision_extract.api_base.as_deref(),
773            Some("http://localhost:1234/v1")
774        );
775        assert_eq!(
776            bindings.text_generate.api_base.as_deref(),
777            Some("http://localhost:1234/v1")
778        );
779        assert_eq!(bindings.audio_transcribe.api_base, None);
780        assert_eq!(bindings.audio_translate.api_base, None);
781    }
782}