1use 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#[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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub struct AiContextOptions {
74 pub no_ai: bool,
75 pub forced_routing: Option<AiRouting>,
76}
77
78#[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
157pub fn route(context: &AiContext, capability: AiCapability) -> AiRouting {
159 context.binding(capability).routing
160}
161
162#[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#[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#[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#[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#[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}