1use std::collections::{BTreeMap, BTreeSet};
12use std::path::PathBuf;
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use crate::Config;
18use crate::auth::{AuthConstraints, AuthMetadataDefaults};
19use crate::provider::Provider;
20use crate::provider_matrix::{
21 AnthropicBackendKind, GoogleBackendKind, OpenAiBackendKind, SelfHostedBackendKind,
22};
23
24const AZURE_OPENAI_API_KEY_ENV: &str = "AZURE_OPENAI_API_KEY";
25const AZURE_OPENAI_ENDPOINT_ENV: &str = "AZURE_OPENAI_ENDPOINT";
26const AZURE_OPENAI_IMAGE_GENERATION_DEPLOYMENT_ENV: &str =
27 "AZURE_OPENAI_IMAGE_GENERATION_DEPLOYMENT";
28const AZURE_OPENAI_IMAGE_DEPLOYMENT_ENV: &str = "AZURE_OPENAI_IMAGE_DEPLOYMENT";
29const AZURE_OPENAI_IMAGE_GENERATION_API_VERSION_ENV: &str =
30 "AZURE_OPENAI_IMAGE_GENERATION_API_VERSION";
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33struct EnvDefaultSpec {
34 backend_kind: &'static str,
35 auth_method: &'static str,
36 env_var: &'static str,
37 fallback: Vec<String>,
38 base_url: Option<String>,
39 options: serde_json::Value,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Error)]
48pub enum IdentityError {
49 #[error("identity slug is empty")]
50 Empty,
51 #[error(
52 "identity slug contains invalid character {0:?}; must be ASCII alphanumeric or one of '-', '_', '.'"
53 )]
54 InvalidChar(char),
55}
56
57fn validate_slug(raw: &str) -> Result<(), IdentityError> {
58 if raw.is_empty() {
59 return Err(IdentityError::Empty);
60 }
61 for ch in raw.chars() {
62 if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.') {
63 return Err(IdentityError::InvalidChar(ch));
64 }
65 }
66 Ok(())
67}
68
69macro_rules! slug_newtype {
70 ($name:ident, $doc:literal) => {
71 #[doc = $doc]
72 #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
73 #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74 #[serde(try_from = "String", into = "String")]
75 pub struct $name(String);
76
77 impl $name {
78 pub fn parse(raw: impl Into<String>) -> Result<Self, IdentityError> {
79 let raw = raw.into();
80 validate_slug(&raw)?;
81 Ok(Self(raw))
82 }
83
84 pub fn as_str(&self) -> &str {
85 &self.0
86 }
87 }
88
89 impl TryFrom<String> for $name {
90 type Error = IdentityError;
91 fn try_from(s: String) -> Result<Self, Self::Error> {
92 Self::parse(s)
93 }
94 }
95
96 impl From<$name> for String {
97 fn from(v: $name) -> String {
98 v.0
99 }
100 }
101
102 impl std::fmt::Display for $name {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(&self.0)
105 }
106 }
107 };
108}
109
110slug_newtype!(RealmId, "Opaque slug identifying a realm.");
111slug_newtype!(
112 BindingId,
113 "Opaque slug identifying a binding inside a realm."
114);
115slug_newtype!(
116 ProfileId,
117 "Opaque slug identifying an auth profile override on a connection."
118);
119
120#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
128#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
129pub struct AuthBindingRef {
130 pub realm: RealmId,
131 pub binding: BindingId,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub profile: Option<ProfileId>,
134}
135
136impl AuthBindingRef {
137 pub fn is_env_default(&self) -> bool {
138 self.realm.as_str() == "env_default"
139 && self.binding.as_str() == "default"
140 && self.profile.is_none()
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
146#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
147pub struct BackendProfile {
148 pub id: String,
149 pub provider: Provider,
150 pub backend_kind: String,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub base_url: Option<String>,
153 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
154 pub options: serde_json::Value,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160pub struct AuthProfile {
161 pub id: String,
162 pub provider: Provider,
163 pub auth_method: String,
164 pub source: CredentialSourceSpec,
165 #[serde(default)]
166 pub constraints: AuthConstraints,
167 #[serde(default)]
168 pub metadata_defaults: AuthMetadataDefaults,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174#[serde(tag = "kind", rename_all = "snake_case")]
175pub enum CredentialSourceSpec {
176 InlineSecret {
177 secret: String,
178 },
179 ManagedStore,
184 Env {
185 env: String,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 fallback: Vec<String>,
193 },
194 ExternalResolver {
195 handle: String,
196 },
197 PlatformDefault,
198 Command {
202 program: PathBuf,
203 #[serde(default)]
204 args: Vec<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
206 cwd: Option<PathBuf>,
207 #[serde(default)]
208 env: BTreeMap<String, String>,
209 #[serde(default = "default_command_timeout_ms")]
211 timeout_ms: u64,
212 #[serde(default, skip_serializing_if = "Option::is_none")]
214 refresh_interval_ms: Option<u64>,
215 },
216 FileDescriptor {
219 fd: i32,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 scope_override: Option<String>,
222 },
223}
224
225impl CredentialSourceSpec {
226 pub const ALL_KIND_LABELS: &'static [&'static str] = &[
227 "inline_secret",
228 "managed_store",
229 "env",
230 "external_resolver",
231 "platform_default",
232 "command",
233 "file_descriptor",
234 ];
235
236 pub const fn kind_label(&self) -> &'static str {
237 match self {
238 Self::InlineSecret { .. } => "inline_secret",
239 Self::ManagedStore => "managed_store",
240 Self::Env { .. } => "env",
241 Self::ExternalResolver { .. } => "external_resolver",
242 Self::PlatformDefault => "platform_default",
243 Self::Command { .. } => "command",
244 Self::FileDescriptor { .. } => "file_descriptor",
245 }
246 }
247}
248
249fn default_command_timeout_ms() -> u64 {
250 30_000
251}
252
253#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
255#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
256pub struct BindingPolicy {
257 #[serde(default)]
258 pub allow_auth_override: bool,
259 #[serde(default)]
260 pub require_metadata_account: bool,
261 #[serde(default)]
262 pub require_metadata_workspace: bool,
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
268#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
269pub struct ProviderBinding {
270 pub id: String,
271 pub backend_profile: String,
272 pub auth_profile: String,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub default_model: Option<String>,
275 #[serde(default)]
276 pub policy: BindingPolicy,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
285pub struct RealmConnectionSet {
286 pub realm_id: String,
287 pub backends: BTreeMap<String, BackendProfile>,
288 pub auth_profiles: BTreeMap<String, AuthProfile>,
289 pub bindings: BTreeMap<String, ProviderBinding>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub default_binding: Option<String>,
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
298pub struct ResolvedConnectionTarget {
299 pub realm: RealmConnectionSet,
300 pub auth_binding: AuthBindingRef,
301 pub binding: ProviderBinding,
302 pub backend: BackendProfile,
303 pub auth_profile: AuthProfile,
304}
305
306#[derive(Debug, Clone, Error, PartialEq, Eq)]
307pub enum ConnectionTargetError {
308 #[error("connection target did not name a realm and no configured default realm was available")]
309 MissingRealm,
310 #[error("realm '{0}' not found in config.realm")]
311 UnknownRealm(String),
312 #[error("realm '{realm}' has no default binding")]
313 MissingDefaultBinding { realm: String },
314 #[error("invalid realm id '{realm}': {source}")]
315 InvalidRealmId {
316 realm: String,
317 source: IdentityError,
318 },
319 #[error("invalid binding id '{binding}': {source}")]
320 InvalidBindingId {
321 binding: String,
322 source: IdentityError,
323 },
324 #[error("realm '{realm}' config invalid: {source}")]
325 RealmConfigInvalid {
326 realm: String,
327 source: ProviderBindingError,
328 },
329 #[error("binding '{realm}:{binding}' is invalid: {source}")]
330 BindingInvalid {
331 realm: String,
332 binding: String,
333 source: ProviderBindingError,
334 },
335 #[error(
336 "binding '{realm}:{binding}' resolves backend={backend:?} auth={auth:?}, expected provider {expected:?}"
337 )]
338 ProviderMismatch {
339 realm: String,
340 binding: String,
341 expected: Provider,
342 backend: Provider,
343 auth: Provider,
344 },
345}
346
347pub fn resolve_realm_binding_target_for_provider(
355 config: &Config,
356 provider: Provider,
357 explicit_realm: Option<&RealmId>,
358 explicit_binding: Option<&BindingId>,
359 explicit_profile: Option<&ProfileId>,
360 preferred_realm: Option<&RealmId>,
361 allow_env_default: bool,
362) -> Result<ResolvedConnectionTarget, ConnectionTargetError> {
363 let mut candidates: Vec<&str> = Vec::new();
364 if let Some(realm) = explicit_realm {
365 candidates.push(realm.as_str());
366 } else {
367 if let Some(realm) = preferred_realm {
368 candidates.push(realm.as_str());
369 }
370 if !candidates.contains(&"default") {
371 candidates.push("default");
372 }
373 }
374
375 let mut missing_default: Option<String> = None;
376 for realm_id in candidates {
377 let Some(section) = config.realm.get(realm_id) else {
378 if explicit_realm.is_some() {
379 return Err(ConnectionTargetError::UnknownRealm(realm_id.to_string()));
380 }
381 continue;
382 };
383 let realm = RealmConnectionSet::from_config(realm_id, section).map_err(|source| {
384 ConnectionTargetError::RealmConfigInvalid {
385 realm: realm_id.to_string(),
386 source,
387 }
388 })?;
389 let binding_id = match explicit_binding {
390 Some(binding) => binding.clone(),
391 None => {
392 let Some(default_binding) = realm.default_binding.as_deref() else {
393 missing_default = Some(realm_id.to_string());
394 if explicit_realm.is_some() {
395 return Err(ConnectionTargetError::MissingDefaultBinding {
396 realm: realm_id.to_string(),
397 });
398 }
399 continue;
400 };
401 BindingId::parse(default_binding).map_err(|source| {
402 ConnectionTargetError::InvalidBindingId {
403 binding: default_binding.to_string(),
404 source,
405 }
406 })?
407 }
408 };
409 return materialize_connection_target(
410 realm,
411 provider,
412 binding_id,
413 explicit_profile.cloned(),
414 );
415 }
416
417 if allow_env_default && explicit_realm.is_none() && explicit_binding.is_none() {
418 let realm = RealmConnectionSet::synthesize_env_default(provider);
419 let binding = BindingId::parse("default").map_err(|source| {
420 ConnectionTargetError::InvalidBindingId {
421 binding: "default".to_string(),
422 source,
423 }
424 })?;
425 return materialize_connection_target(realm, provider, binding, explicit_profile.cloned());
426 }
427
428 if let Some(realm) = missing_default {
429 return Err(ConnectionTargetError::MissingDefaultBinding { realm });
430 }
431 Err(ConnectionTargetError::MissingRealm)
432}
433
434pub fn resolve_auth_binding_or_default_for_provider(
438 config: &Config,
439 provider: Provider,
440 auth_binding: Option<&AuthBindingRef>,
441 preferred_realm: Option<&RealmId>,
442 allow_env_default: bool,
443) -> Result<ResolvedConnectionTarget, ConnectionTargetError> {
444 if let Some(auth_binding) = auth_binding {
445 let realm_id = auth_binding.realm.as_str();
446 if auth_binding.is_env_default() {
447 return Err(ConnectionTargetError::UnknownRealm(realm_id.to_string()));
448 }
449 let section = config
450 .realm
451 .get(realm_id)
452 .ok_or_else(|| ConnectionTargetError::UnknownRealm(realm_id.to_string()))?;
453 let realm = RealmConnectionSet::from_config(realm_id, section).map_err(|source| {
454 ConnectionTargetError::RealmConfigInvalid {
455 realm: realm_id.to_string(),
456 source,
457 }
458 })?;
459 return materialize_connection_target(
460 realm,
461 provider,
462 auth_binding.binding.clone(),
463 auth_binding.profile.clone(),
464 );
465 }
466
467 resolve_realm_binding_target_for_provider(
468 config,
469 provider,
470 None,
471 None,
472 None,
473 preferred_realm,
474 allow_env_default,
475 )
476}
477
478fn selected_binding_id_for_provider(
479 realm: &RealmConnectionSet,
480 provider: Provider,
481) -> Result<Option<BindingId>, ConnectionTargetError> {
482 let mut provider_bindings = Vec::new();
483 for (binding_id, binding) in &realm.bindings {
484 let backend = realm
485 .backends
486 .get(&binding.backend_profile)
487 .ok_or_else(|| ConnectionTargetError::BindingInvalid {
488 realm: realm.realm_id.clone(),
489 binding: binding_id.clone(),
490 source: ProviderBindingError::UnknownBackend(binding.backend_profile.clone()),
491 })?;
492 let auth = realm
493 .auth_profiles
494 .get(&binding.auth_profile)
495 .ok_or_else(|| ConnectionTargetError::BindingInvalid {
496 realm: realm.realm_id.clone(),
497 binding: binding_id.clone(),
498 source: ProviderBindingError::UnknownAuth(binding.auth_profile.clone()),
499 })?;
500 if backend.provider == provider && auth.provider == provider {
501 provider_bindings.push(binding_id.as_str());
502 }
503 }
504
505 if let Some(default_binding) = realm.default_binding.as_deref()
506 && provider_bindings.contains(&default_binding)
507 {
508 return BindingId::parse(default_binding.to_string())
509 .map(Some)
510 .map_err(|source| ConnectionTargetError::InvalidBindingId {
511 binding: default_binding.to_string(),
512 source,
513 });
514 }
515
516 let provider_default_binding = format!("default_{}", provider.as_str());
517 if provider_bindings
518 .iter()
519 .any(|binding_id| *binding_id == provider_default_binding)
520 {
521 return BindingId::parse(provider_default_binding.clone())
522 .map(Some)
523 .map_err(|source| ConnectionTargetError::InvalidBindingId {
524 binding: provider_default_binding,
525 source,
526 });
527 }
528
529 match provider_bindings.as_slice() {
530 [binding_id] => BindingId::parse((*binding_id).to_string())
531 .map(Some)
532 .map_err(|source| ConnectionTargetError::InvalidBindingId {
533 binding: (*binding_id).to_string(),
534 source,
535 }),
536 _ => Ok(None),
537 }
538}
539
540fn push_candidate_realm_ids<'a>(
541 ids: &mut Vec<&'a str>,
542 seen: &mut BTreeSet<&'a str>,
543 id: Option<&'a str>,
544) {
545 if let Some(id) = id
546 && seen.insert(id)
547 {
548 ids.push(id);
549 }
550}
551
552pub fn resolve_auth_binding_candidates_for_provider(
565 config: &Config,
566 provider: Provider,
567 auth_binding: Option<&AuthBindingRef>,
568 preferred_realm: Option<&RealmId>,
569 allow_env_default: bool,
570) -> Result<Vec<ResolvedConnectionTarget>, ConnectionTargetError> {
571 if auth_binding.is_some() {
572 return resolve_auth_binding_or_default_for_provider(
573 config,
574 provider,
575 auth_binding,
576 preferred_realm,
577 allow_env_default,
578 )
579 .map(|target| vec![target]);
580 }
581
582 let mut realm_ids = Vec::new();
583 let mut seen = BTreeSet::new();
584 push_candidate_realm_ids(
585 &mut realm_ids,
586 &mut seen,
587 preferred_realm.map(RealmId::as_str),
588 );
589 push_candidate_realm_ids(&mut realm_ids, &mut seen, Some("default"));
590 for realm_id in config.realm.keys() {
591 push_candidate_realm_ids(&mut realm_ids, &mut seen, Some(realm_id.as_str()));
592 }
593
594 let mut candidates = Vec::new();
595 let mut missing_default: Option<String> = None;
596 for realm_id in realm_ids {
597 let Some(section) = config.realm.get(realm_id) else {
598 if preferred_realm.is_some_and(|preferred| preferred.as_str() == realm_id) {
599 missing_default.get_or_insert_with(|| realm_id.to_string());
600 }
601 continue;
602 };
603 let realm = RealmConnectionSet::from_config(realm_id, section).map_err(|source| {
604 ConnectionTargetError::RealmConfigInvalid {
605 realm: realm_id.to_string(),
606 source,
607 }
608 })?;
609 if let Some(binding_id) = selected_binding_id_for_provider(&realm, provider)? {
610 candidates.push(materialize_connection_target(
611 realm, provider, binding_id, None,
612 )?);
613 }
614 }
615
616 if allow_env_default {
617 let realm = RealmConnectionSet::synthesize_env_default(provider);
618 let binding = BindingId::parse("default").map_err(|source| {
619 ConnectionTargetError::InvalidBindingId {
620 binding: "default".to_string(),
621 source,
622 }
623 })?;
624 candidates.push(materialize_connection_target(
625 realm, provider, binding, None,
626 )?);
627 }
628
629 if !candidates.is_empty() {
630 return Ok(candidates);
631 }
632 if let Some(realm) = missing_default {
633 return Err(ConnectionTargetError::MissingDefaultBinding { realm });
634 }
635 Err(ConnectionTargetError::MissingRealm)
636}
637
638fn materialize_connection_target(
639 realm: RealmConnectionSet,
640 provider: Provider,
641 binding: BindingId,
642 profile: Option<ProfileId>,
643) -> Result<ResolvedConnectionTarget, ConnectionTargetError> {
644 let realm_typed = RealmId::parse(realm.realm_id.clone()).map_err(|source| {
645 ConnectionTargetError::InvalidRealmId {
646 realm: realm.realm_id.clone(),
647 source,
648 }
649 })?;
650 let auth_binding = AuthBindingRef {
651 realm: realm_typed,
652 binding,
653 profile,
654 };
655 let (binding, backend, auth_profile) =
656 realm.lookup_auth_binding(&auth_binding).map_err(|source| {
657 ConnectionTargetError::BindingInvalid {
658 realm: auth_binding.realm.to_string(),
659 binding: auth_binding.binding.to_string(),
660 source,
661 }
662 })?;
663 if backend.provider != provider || auth_profile.provider != provider {
664 return Err(ConnectionTargetError::ProviderMismatch {
665 realm: auth_binding.realm.to_string(),
666 binding: auth_binding.binding.to_string(),
667 expected: provider,
668 backend: backend.provider,
669 auth: auth_profile.provider,
670 });
671 }
672 let binding = binding.clone();
673 let backend = backend.clone();
674 let auth_profile = auth_profile.clone();
675 Ok(ResolvedConnectionTarget {
676 realm,
677 auth_binding,
678 binding,
679 backend,
680 auth_profile,
681 })
682}
683
684impl RealmConnectionSet {
685 pub fn from_config(
690 realm_id: &str,
691 section: &RealmConfigSection,
692 ) -> Result<Self, ProviderBindingError> {
693 let mut backends: BTreeMap<String, BackendProfile> = BTreeMap::new();
694 for (id, cfg) in §ion.backend {
695 let provider = Provider::parse_strict(&cfg.provider)
696 .ok_or_else(|| ProviderBindingError::UnknownProviderName(cfg.provider.clone()))?;
697 let backend = BackendProfile {
698 id: id.clone(),
699 provider,
700 backend_kind: cfg.backend_kind.clone(),
701 base_url: cfg.base_url.clone(),
702 options: cfg.options.clone(),
703 };
704 backends.insert(id.clone(), backend);
707 }
708
709 let mut auth_profiles: BTreeMap<String, AuthProfile> = BTreeMap::new();
710 for (id, cfg) in §ion.auth {
711 let provider = Provider::parse_strict(&cfg.provider)
712 .ok_or_else(|| ProviderBindingError::UnknownProviderName(cfg.provider.clone()))?;
713 let profile = AuthProfile {
714 id: id.clone(),
715 provider,
716 auth_method: cfg.auth_method.clone(),
717 source: cfg.source.clone(),
718 constraints: cfg.constraints.clone(),
719 metadata_defaults: cfg.metadata_defaults.clone(),
720 };
721 auth_profiles.insert(id.clone(), profile);
722 }
723
724 let mut bindings: BTreeMap<String, ProviderBinding> = BTreeMap::new();
725 for (id, cfg) in §ion.binding {
726 let backend = backends
727 .get(&cfg.backend_profile)
728 .ok_or_else(|| ProviderBindingError::UnknownBackend(cfg.backend_profile.clone()))?;
729 let auth = auth_profiles
730 .get(&cfg.auth_profile)
731 .ok_or_else(|| ProviderBindingError::UnknownAuth(cfg.auth_profile.clone()))?;
732 if backend.provider != auth.provider {
733 return Err(ProviderBindingError::ProviderMismatch {
734 binding: id.clone(),
735 backend: backend.provider,
736 auth: auth.provider,
737 });
738 }
739 let binding = ProviderBinding {
740 id: id.clone(),
741 backend_profile: cfg.backend_profile.clone(),
742 auth_profile: cfg.auth_profile.clone(),
743 default_model: cfg.default_model.clone(),
744 policy: cfg.policy.clone(),
745 };
746 bindings.insert(id.clone(), binding);
747 }
748
749 Ok(Self {
750 realm_id: realm_id.to_string(),
751 backends,
752 auth_profiles,
753 bindings,
754 default_binding: section.default_binding.clone(),
755 })
756 }
757
758 pub fn synthesize_env_default(provider: Provider) -> Self {
786 Self::synthesize_env_default_from_lookup(provider, |key| std::env::var(key).ok())
787 }
788
789 pub fn synthesize_env_default_from_lookup<F>(provider: Provider, env_lookup: F) -> Self
792 where
793 F: Fn(&str) -> Option<String>,
794 {
795 let spec = env_default_spec(provider, env_lookup);
796 Self::synthesize_default_from_spec(provider, None, spec)
797 }
798
799 pub fn synthesize_inline_default(provider: Provider, secret: String) -> Self {
803 Self::synthesize_default_from_spec(
804 provider,
805 Some(secret),
806 env_default_spec(provider, |_| None),
807 )
808 }
809
810 fn synthesize_default_from_spec(
811 provider: Provider,
812 inline_secret: Option<String>,
813 spec: EnvDefaultSpec,
814 ) -> Self {
815 let backend = BackendProfile {
816 id: "default".to_string(),
817 provider,
818 backend_kind: spec.backend_kind.to_string(),
819 base_url: spec.base_url,
820 options: spec.options,
821 };
822 let source = match inline_secret {
823 Some(secret) => CredentialSourceSpec::InlineSecret { secret },
824 None => CredentialSourceSpec::Env {
825 env: spec.env_var.to_string(),
826 fallback: spec.fallback,
827 },
828 };
829 let auth = AuthProfile {
830 id: "default".to_string(),
831 provider,
832 auth_method: spec.auth_method.to_string(),
833 source,
834 constraints: AuthConstraints::default(),
835 metadata_defaults: AuthMetadataDefaults::default(),
836 };
837 let binding = ProviderBinding {
838 id: "default".to_string(),
839 backend_profile: "default".to_string(),
840 auth_profile: "default".to_string(),
841 default_model: None,
842 policy: BindingPolicy::default(),
843 };
844 let mut backends = BTreeMap::new();
845 backends.insert("default".to_string(), backend);
846 let mut auth_profiles = BTreeMap::new();
847 auth_profiles.insert("default".to_string(), auth);
848 let mut bindings = BTreeMap::new();
849 bindings.insert("default".to_string(), binding);
850 Self {
851 realm_id: "env_default".to_string(),
852 backends,
853 auth_profiles,
854 bindings,
855 default_binding: Some("default".to_string()),
856 }
857 }
858
859 pub fn lookup_binding(
862 &self,
863 id: &str,
864 ) -> Result<(&ProviderBinding, &BackendProfile, &AuthProfile), ProviderBindingError> {
865 let binding = self
866 .bindings
867 .get(id)
868 .ok_or_else(|| ProviderBindingError::UnknownBinding(id.to_string()))?;
869 let backend = self
870 .backends
871 .get(&binding.backend_profile)
872 .ok_or_else(|| ProviderBindingError::UnknownBackend(binding.backend_profile.clone()))?;
873 let auth = self
874 .auth_profiles
875 .get(&binding.auth_profile)
876 .ok_or_else(|| ProviderBindingError::UnknownAuth(binding.auth_profile.clone()))?;
877 Ok((binding, backend, auth))
878 }
879
880 pub fn lookup_auth_binding(
884 &self,
885 auth_binding: &AuthBindingRef,
886 ) -> Result<(&ProviderBinding, &BackendProfile, &AuthProfile), ProviderBindingError> {
887 let binding = self
888 .bindings
889 .get(auth_binding.binding.as_str())
890 .ok_or_else(|| {
891 ProviderBindingError::UnknownBinding(auth_binding.binding.to_string())
892 })?;
893 let backend = self
894 .backends
895 .get(&binding.backend_profile)
896 .ok_or_else(|| ProviderBindingError::UnknownBackend(binding.backend_profile.clone()))?;
897 let auth_profile_id = auth_binding
898 .profile
899 .as_ref()
900 .map(ProfileId::as_str)
901 .unwrap_or(binding.auth_profile.as_str());
902 let auth = self
903 .auth_profiles
904 .get(auth_profile_id)
905 .ok_or_else(|| ProviderBindingError::UnknownAuth(auth_profile_id.to_string()))?;
906 Ok((binding, backend, auth))
907 }
908}
909
910#[derive(Debug, Clone, Error, Serialize, Deserialize, PartialEq, Eq)]
920#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
921#[serde(tag = "kind", rename_all = "snake_case")]
922pub enum ProviderBindingError {
923 #[error("unknown binding: {0}")]
924 UnknownBinding(String),
925 #[error("unknown backend: {0}")]
926 UnknownBackend(String),
927 #[error("unknown auth: {0}")]
928 UnknownAuth(String),
929 #[error("provider mismatch on binding {binding}: backend={backend:?} auth={auth:?}")]
930 ProviderMismatch {
931 binding: String,
932 backend: Provider,
933 auth: Provider,
934 },
935 #[error("unknown provider name: {0}")]
936 UnknownProviderName(String),
937}
938
939#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
949#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
950pub struct RealmConfigSection {
951 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
952 pub backend: BTreeMap<String, BackendProfileConfig>,
953 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
954 pub auth: BTreeMap<String, AuthProfileConfig>,
955 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
956 pub binding: BTreeMap<String, ProviderBindingConfig>,
957 #[serde(default, skip_serializing_if = "Option::is_none")]
958 pub default_binding: Option<String>,
959}
960
961impl RealmConfigSection {
962 pub fn from_inline_api_keys(entries: &[(&str, &str)]) -> Self {
978 let mut backend = BTreeMap::new();
979 let mut auth = BTreeMap::new();
980 let mut binding = BTreeMap::new();
981 let mut default_binding: Option<String> = None;
982
983 for (idx, (provider, secret)) in entries.iter().enumerate() {
984 let id = format!("default_{provider}");
985 let backend_kind = match *provider {
986 "anthropic" => "anthropic_api",
987 "openai" => "openai_api",
988 "gemini" | "google" => "google_genai",
989 other => other,
990 };
991 backend.insert(
992 id.clone(),
993 BackendProfileConfig {
994 provider: provider.to_string(),
995 backend_kind: backend_kind.to_string(),
996 base_url: None,
997 options: serde_json::Value::Null,
998 },
999 );
1000 auth.insert(
1001 id.clone(),
1002 AuthProfileConfig {
1003 provider: provider.to_string(),
1004 auth_method: "api_key".to_string(),
1005 source: CredentialSourceSpec::InlineSecret {
1006 secret: (*secret).to_string(),
1007 },
1008 constraints: AuthConstraints::default(),
1009 metadata_defaults: AuthMetadataDefaults::default(),
1010 },
1011 );
1012 binding.insert(
1013 id.clone(),
1014 ProviderBindingConfig {
1015 backend_profile: id.clone(),
1016 auth_profile: id.clone(),
1017 default_model: None,
1018 policy: BindingPolicy::default(),
1019 },
1020 );
1021 if idx == 0 {
1022 default_binding = Some(id);
1023 }
1024 }
1025
1026 Self {
1027 backend,
1028 auth,
1029 binding,
1030 default_binding,
1031 }
1032 }
1033}
1034
1035fn env_default_spec<F>(provider: Provider, env_lookup: F) -> EnvDefaultSpec
1036where
1037 F: Fn(&str) -> Option<String>,
1038{
1039 match provider {
1040 Provider::Anthropic => EnvDefaultSpec {
1041 backend_kind: AnthropicBackendKind::AnthropicApi.as_str(),
1042 auth_method: "api_key",
1043 env_var: "ANTHROPIC_API_KEY",
1044 fallback: vec![],
1045 base_url: None,
1046 options: serde_json::Value::Null,
1047 },
1048 Provider::OpenAI => openai_env_default_spec(env_lookup),
1049 Provider::Gemini => EnvDefaultSpec {
1050 backend_kind: GoogleBackendKind::GoogleGenAi.as_str(),
1051 auth_method: "api_key",
1052 env_var: "GEMINI_API_KEY",
1053 fallback: vec!["GOOGLE_API_KEY".to_string()],
1054 base_url: None,
1055 options: serde_json::Value::Null,
1056 },
1057 Provider::SelfHosted => EnvDefaultSpec {
1058 backend_kind: SelfHostedBackendKind::SelfHosted.as_str(),
1059 auth_method: "api_key",
1060 env_var: "RKAT_SELF_HOSTED_API_KEY",
1061 fallback: vec![],
1062 base_url: None,
1063 options: serde_json::Value::Null,
1064 },
1065 Provider::Other => EnvDefaultSpec {
1066 backend_kind: "other_api",
1067 auth_method: "api_key",
1068 env_var: "RKAT_OTHER_API_KEY",
1069 fallback: vec![],
1070 base_url: None,
1071 options: serde_json::Value::Null,
1072 },
1073 }
1074}
1075
1076fn openai_env_default_spec<F>(env_lookup: F) -> EnvDefaultSpec
1077where
1078 F: Fn(&str) -> Option<String>,
1079{
1080 let public_openai_key = env_value_with_rkat(&env_lookup, "OPENAI_API_KEY");
1081 let azure_key = env_value_with_rkat(&env_lookup, AZURE_OPENAI_API_KEY_ENV);
1082 let azure_endpoint = env_value_with_rkat(&env_lookup, AZURE_OPENAI_ENDPOINT_ENV);
1083 let azure_explicit = direct_env_value(&env_lookup, &format!("RKAT_{AZURE_OPENAI_API_KEY_ENV}"))
1084 .is_some()
1085 || direct_env_value(&env_lookup, &format!("RKAT_{AZURE_OPENAI_ENDPOINT_ENV}")).is_some();
1086 if azure_key.is_some()
1087 && let Some(endpoint) = azure_endpoint
1088 && (azure_explicit || public_openai_key.is_none())
1089 {
1090 let mut options = serde_json::Map::new();
1091 if let Some(deployment) =
1092 env_value_with_rkat(&env_lookup, AZURE_OPENAI_IMAGE_GENERATION_DEPLOYMENT_ENV)
1093 .or_else(|| env_value_with_rkat(&env_lookup, AZURE_OPENAI_IMAGE_DEPLOYMENT_ENV))
1094 {
1095 options.insert(
1096 "image_generation_deployment".to_string(),
1097 serde_json::Value::String(deployment),
1098 );
1099 }
1100 if let Some(api_version) =
1101 env_value_with_rkat(&env_lookup, AZURE_OPENAI_IMAGE_GENERATION_API_VERSION_ENV)
1102 {
1103 options.insert(
1104 "image_generation_api_version".to_string(),
1105 serde_json::Value::String(api_version),
1106 );
1107 }
1108 return EnvDefaultSpec {
1109 backend_kind: OpenAiBackendKind::AzureOpenAi.as_str(),
1110 auth_method: "azure_api_key",
1111 env_var: AZURE_OPENAI_API_KEY_ENV,
1112 fallback: vec![],
1113 base_url: Some(endpoint),
1114 options: if options.is_empty() {
1115 serde_json::Value::Null
1116 } else {
1117 serde_json::Value::Object(options)
1118 },
1119 };
1120 }
1121 EnvDefaultSpec {
1122 backend_kind: OpenAiBackendKind::OpenAiApi.as_str(),
1123 auth_method: "api_key",
1124 env_var: "OPENAI_API_KEY",
1125 fallback: vec![],
1126 base_url: None,
1127 options: serde_json::Value::Null,
1128 }
1129}
1130
1131fn env_value_with_rkat<F>(env_lookup: &F, candidate: &str) -> Option<String>
1132where
1133 F: Fn(&str) -> Option<String>,
1134{
1135 let rkat_override = if candidate.starts_with("RKAT_") {
1136 None
1137 } else {
1138 direct_env_value(env_lookup, &format!("RKAT_{candidate}"))
1139 };
1140 rkat_override.or_else(|| direct_env_value(env_lookup, candidate))
1141}
1142
1143fn direct_env_value<F>(env_lookup: &F, key: &str) -> Option<String>
1144where
1145 F: Fn(&str) -> Option<String>,
1146{
1147 env_lookup(key)
1148 .map(|value| value.trim().to_string())
1149 .filter(|value| !value.is_empty())
1150}
1151
1152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1155pub struct BackendProfileConfig {
1156 pub provider: String,
1157 pub backend_kind: String,
1158 #[serde(default, skip_serializing_if = "Option::is_none")]
1159 pub base_url: Option<String>,
1160 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
1161 pub options: serde_json::Value,
1162}
1163
1164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1167pub struct AuthProfileConfig {
1168 pub provider: String,
1169 pub auth_method: String,
1170 pub source: CredentialSourceSpec,
1171 #[serde(default)]
1172 pub constraints: AuthConstraints,
1173 #[serde(default)]
1174 pub metadata_defaults: AuthMetadataDefaults,
1175}
1176
1177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1179#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1180pub struct ProviderBindingConfig {
1181 pub backend_profile: String,
1182 pub auth_profile: String,
1183 #[serde(default, skip_serializing_if = "Option::is_none")]
1184 pub default_model: Option<String>,
1185 #[serde(default)]
1186 pub policy: BindingPolicy,
1187}
1188
1189#[cfg(test)]
1190#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1191mod tests {
1192 use super::*;
1193
1194 fn config_with_realms(toml_input: &str) -> Config {
1195 Config {
1196 realm: toml::from_str(toml_input).unwrap(),
1197 ..Default::default()
1198 }
1199 }
1200
1201 fn openai_target_config() -> Config {
1202 config_with_realms(
1203 r#"
1204[prod]
1205default_binding = "primary"
1206
1207[prod.backend.openai_default]
1208provider = "openai"
1209backend_kind = "openai_api"
1210
1211[prod.auth.openai_oauth]
1212provider = "openai"
1213auth_method = "chatgpt_oauth"
1214source = { kind = "platform_default" }
1215
1216[prod.binding.primary]
1217backend_profile = "openai_default"
1218auth_profile = "openai_oauth"
1219
1220[prod.binding.secondary]
1221backend_profile = "openai_default"
1222auth_profile = "openai_oauth"
1223"#,
1224 )
1225 }
1226
1227 fn lookup_from_pairs(
1228 pairs: &'static [(&'static str, &'static str)],
1229 ) -> impl Fn(&str) -> Option<String> {
1230 move |key| {
1231 pairs
1232 .iter()
1233 .find_map(|(candidate, value)| (*candidate == key).then(|| (*value).to_string()))
1234 }
1235 }
1236
1237 #[test]
1238 fn auth_binding_is_purely_structural() {
1239 let c = AuthBindingRef {
1240 realm: RealmId::parse("dev").unwrap(),
1241 binding: BindingId::parse("default_openai").unwrap(),
1242 profile: None,
1243 };
1244 assert_eq!(c.realm.as_str(), "dev");
1245 assert_eq!(c.binding.as_str(), "default_openai");
1246 assert!(c.profile.is_none());
1247 }
1248
1249 #[test]
1250 fn auth_binding_serde_roundtrip_with_profile() {
1251 let c = AuthBindingRef {
1252 realm: RealmId::parse("prod").unwrap(),
1253 binding: BindingId::parse("gpt5").unwrap(),
1254 profile: Some(ProfileId::parse("override").unwrap()),
1255 };
1256 let s = serde_json::to_string(&c).unwrap();
1257 assert!(s.contains("\"realm\":\"prod\""));
1258 assert!(s.contains("\"binding\":\"gpt5\""));
1259 assert!(s.contains("\"profile\":\"override\""));
1260 let back: AuthBindingRef = serde_json::from_str(&s).unwrap();
1261 assert_eq!(back, c);
1262 }
1263
1264 #[test]
1265 fn auth_binding_profile_overrides_binding_auth_profile() {
1266 let toml = r#"
1267realm_id = "prod"
1268default_binding = "primary"
1269
1270[backend.openai_default]
1271provider = "openai"
1272backend_kind = "openai_api"
1273base_url = "https://api.openai.com/v1"
1274
1275[auth.default_profile]
1276provider = "openai"
1277auth_method = "api_key"
1278source = { kind = "env", env = "OPENAI_API_KEY" }
1279
1280[auth.override_profile]
1281provider = "openai"
1282auth_method = "api_key"
1283source = { kind = "env", env = "OVERRIDE_OPENAI_API_KEY" }
1284
1285[binding.primary]
1286backend_profile = "openai_default"
1287auth_profile = "default_profile"
1288"#;
1289 let section: RealmConfigSection = toml::from_str(toml).unwrap();
1290 let realm = RealmConnectionSet::from_config("prod", §ion).unwrap();
1291 let auth_binding = AuthBindingRef {
1292 realm: RealmId::parse("prod").unwrap(),
1293 binding: BindingId::parse("primary").unwrap(),
1294 profile: Some(ProfileId::parse("override_profile").unwrap()),
1295 };
1296
1297 let (_binding, _backend, auth) = realm.lookup_auth_binding(&auth_binding).unwrap();
1298 assert_eq!(auth.id, "override_profile");
1299 }
1300
1301 #[test]
1302 fn identity_slugs_reject_invalid_characters() {
1303 assert!(RealmId::parse("").is_err());
1304 assert!(BindingId::parse("bad space").is_err());
1305 assert!(ProfileId::parse("bad:colon").is_err());
1306 assert!(RealmId::parse("dev").is_ok());
1307 assert!(BindingId::parse("openai_default.v1").is_ok());
1308 }
1309
1310 #[test]
1311 fn credential_source_spec_serde() {
1312 for src in [
1313 CredentialSourceSpec::InlineSecret {
1314 secret: "sk-x".into(),
1315 },
1316 CredentialSourceSpec::ManagedStore,
1317 CredentialSourceSpec::Env {
1318 env: "OPENAI_API_KEY".into(),
1319 fallback: Vec::new(),
1320 },
1321 CredentialSourceSpec::ExternalResolver {
1322 handle: "desktop".into(),
1323 },
1324 CredentialSourceSpec::PlatformDefault,
1325 ] {
1326 let s = serde_json::to_string(&src).unwrap();
1327 let back: CredentialSourceSpec = serde_json::from_str(&s).unwrap();
1328 assert_eq!(back, src);
1329 }
1330 }
1331
1332 #[test]
1333 fn credential_source_spec_rejects_unknown_kind() {
1334 let bad = r#"{"kind":"nonexistent","foo":"bar"}"#;
1335 let err = serde_json::from_str::<CredentialSourceSpec>(bad).unwrap_err();
1336 assert!(
1337 err.to_string().contains("nonexistent") || err.to_string().contains("unknown variant"),
1338 "serde error should mention unknown variant: {err}",
1339 );
1340 }
1341
1342 #[test]
1343 fn env_default_openai_uses_public_openai_without_azure_envelope() {
1344 let realm = RealmConnectionSet::synthesize_env_default_from_lookup(
1345 Provider::OpenAI,
1346 lookup_from_pairs(&[]),
1347 );
1348 let backend = realm.backends.get("default").unwrap();
1349 let auth = realm.auth_profiles.get("default").unwrap();
1350
1351 assert_eq!(backend.backend_kind, "openai_api");
1352 assert_eq!(backend.base_url, None);
1353 assert_eq!(auth.auth_method, "api_key");
1354 assert_eq!(
1355 auth.source,
1356 CredentialSourceSpec::Env {
1357 env: "OPENAI_API_KEY".to_string(),
1358 fallback: Vec::new(),
1359 }
1360 );
1361 }
1362
1363 #[test]
1364 fn env_default_openai_uses_azure_when_key_and_endpoint_are_present() {
1365 let realm = RealmConnectionSet::synthesize_env_default_from_lookup(
1366 Provider::OpenAI,
1367 lookup_from_pairs(&[
1368 ("AZURE_OPENAI_API_KEY", "azure-key"),
1369 ("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com/"),
1370 ("AZURE_OPENAI_IMAGE_GENERATION_DEPLOYMENT", "gpt-image-2"),
1371 ("AZURE_OPENAI_IMAGE_GENERATION_API_VERSION", "preview"),
1372 ]),
1373 );
1374 let backend = realm.backends.get("default").unwrap();
1375 let auth = realm.auth_profiles.get("default").unwrap();
1376
1377 assert_eq!(backend.backend_kind, "azure_openai");
1378 assert_eq!(
1379 backend.base_url.as_deref(),
1380 Some("https://example.openai.azure.com/")
1381 );
1382 assert_eq!(
1383 backend.options["image_generation_deployment"],
1384 "gpt-image-2"
1385 );
1386 assert_eq!(backend.options["image_generation_api_version"], "preview");
1387 assert_eq!(auth.auth_method, "azure_api_key");
1388 assert_eq!(
1389 auth.source,
1390 CredentialSourceSpec::Env {
1391 env: "AZURE_OPENAI_API_KEY".to_string(),
1392 fallback: Vec::new(),
1393 }
1394 );
1395 }
1396
1397 #[test]
1398 fn env_default_openai_keeps_public_key_when_plain_azure_and_public_keys_are_both_set() {
1399 let realm = RealmConnectionSet::synthesize_env_default_from_lookup(
1400 Provider::OpenAI,
1401 lookup_from_pairs(&[
1402 ("OPENAI_API_KEY", "public-key"),
1403 ("AZURE_OPENAI_API_KEY", "azure-key"),
1404 ("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com"),
1405 ]),
1406 );
1407 let backend = realm.backends.get("default").unwrap();
1408
1409 assert_eq!(backend.backend_kind, "openai_api");
1410 assert_eq!(backend.base_url, None);
1411 }
1412
1413 #[test]
1414 fn env_default_openai_rkat_azure_envelope_overrides_public_openai_key() {
1415 let realm = RealmConnectionSet::synthesize_env_default_from_lookup(
1416 Provider::OpenAI,
1417 lookup_from_pairs(&[
1418 ("OPENAI_API_KEY", "public-key"),
1419 ("RKAT_AZURE_OPENAI_API_KEY", "azure-key"),
1420 (
1421 "RKAT_AZURE_OPENAI_ENDPOINT",
1422 "https://example.openai.azure.com",
1423 ),
1424 ]),
1425 );
1426 let backend = realm.backends.get("default").unwrap();
1427 let auth = realm.auth_profiles.get("default").unwrap();
1428
1429 assert_eq!(backend.backend_kind, "azure_openai");
1430 assert_eq!(
1431 backend.base_url.as_deref(),
1432 Some("https://example.openai.azure.com")
1433 );
1434 assert_eq!(auth.auth_method, "azure_api_key");
1435 }
1436
1437 #[test]
1438 fn from_config_empty_section_yields_empty_set() {
1439 let section = RealmConfigSection::default();
1440 let set = RealmConnectionSet::from_config("dev", §ion).expect("empty section is valid");
1441 assert_eq!(set.realm_id, "dev");
1442 assert!(set.backends.is_empty());
1443 assert!(set.auth_profiles.is_empty());
1444 assert!(set.bindings.is_empty());
1445 assert_eq!(set.default_binding, None);
1446 }
1447
1448 #[test]
1449 fn lookup_binding_returns_unknown_binding() {
1450 let set = RealmConnectionSet::from_config("dev", &RealmConfigSection::default())
1451 .expect("empty section valid");
1452 let err = set
1453 .lookup_binding("missing")
1454 .expect_err("empty set has no bindings");
1455 assert_eq!(err, ProviderBindingError::UnknownBinding("missing".into()));
1456 }
1457
1458 #[test]
1459 fn connection_target_uses_configured_realm_default_binding() {
1460 let config = openai_target_config();
1461 let preferred_realm = RealmId::parse("prod").unwrap();
1462 let target = resolve_realm_binding_target_for_provider(
1463 &config,
1464 Provider::OpenAI,
1465 None,
1466 None,
1467 None,
1468 Some(&preferred_realm),
1469 false,
1470 )
1471 .unwrap();
1472
1473 assert_eq!(target.auth_binding.realm.as_str(), "prod");
1474 assert_eq!(target.auth_binding.binding.as_str(), "primary");
1475 assert_eq!(target.binding.id, "primary");
1476 }
1477
1478 #[test]
1479 fn connection_target_explicit_binding_wins_with_preferred_realm() {
1480 let config = openai_target_config();
1481 let preferred_realm = RealmId::parse("prod").unwrap();
1482 let binding = BindingId::parse("secondary").unwrap();
1483 let target = resolve_realm_binding_target_for_provider(
1484 &config,
1485 Provider::OpenAI,
1486 None,
1487 Some(&binding),
1488 None,
1489 Some(&preferred_realm),
1490 false,
1491 )
1492 .unwrap();
1493
1494 assert_eq!(target.auth_binding.realm.as_str(), "prod");
1495 assert_eq!(target.auth_binding.binding.as_str(), "secondary");
1496 assert_eq!(target.binding.id, "secondary");
1497 }
1498
1499 #[test]
1500 fn connection_target_rejects_provider_mismatch() {
1501 let config = openai_target_config();
1502 let preferred_realm = RealmId::parse("prod").unwrap();
1503 let err = resolve_realm_binding_target_for_provider(
1504 &config,
1505 Provider::Anthropic,
1506 None,
1507 None,
1508 None,
1509 Some(&preferred_realm),
1510 false,
1511 )
1512 .unwrap_err();
1513
1514 assert!(matches!(
1515 err,
1516 ConnectionTargetError::ProviderMismatch {
1517 expected: Provider::Anthropic,
1518 backend: Provider::OpenAI,
1519 auth: Provider::OpenAI,
1520 ..
1521 }
1522 ));
1523 }
1524
1525 #[test]
1526 fn auth_binding_candidates_prefer_provider_binding_in_preferred_realm() {
1527 let config = config_with_realms(
1528 r#"
1529[dev]
1530default_binding = "openai_oauth"
1531
1532[dev.backend.openai_chatgpt]
1533provider = "openai"
1534backend_kind = "openai_chatgpt"
1535
1536[dev.auth.openai_oauth]
1537provider = "openai"
1538auth_method = "chatgpt_oauth"
1539source = { kind = "managed_store" }
1540
1541[dev.binding.openai_oauth]
1542backend_profile = "openai_chatgpt"
1543auth_profile = "openai_oauth"
1544default_model = "gpt-5.5"
1545"#,
1546 );
1547 let preferred_realm = RealmId::parse("dev").unwrap();
1548
1549 let candidates = resolve_auth_binding_candidates_for_provider(
1550 &config,
1551 Provider::OpenAI,
1552 None,
1553 Some(&preferred_realm),
1554 true,
1555 )
1556 .expect("candidates resolve");
1557
1558 assert_eq!(candidates[0].auth_binding.realm.as_str(), "dev");
1559 assert_eq!(candidates[0].auth_binding.binding.as_str(), "openai_oauth");
1560 assert!(!candidates[0].auth_binding.is_env_default());
1561 }
1562
1563 #[test]
1564 fn auth_binding_candidates_scan_configured_realms_before_env_default() {
1565 let config = config_with_realms(
1566 r#"
1567[dev]
1568
1569[dev.backend.openai_chatgpt]
1570provider = "openai"
1571backend_kind = "openai_chatgpt"
1572
1573[dev.auth.openai_oauth]
1574provider = "openai"
1575auth_method = "chatgpt_oauth"
1576source = { kind = "managed_store" }
1577
1578[dev.binding.openai_oauth]
1579backend_profile = "openai_chatgpt"
1580auth_profile = "openai_oauth"
1581"#,
1582 );
1583 let preferred_realm = RealmId::parse("missing").unwrap();
1584
1585 let candidates = resolve_auth_binding_candidates_for_provider(
1586 &config,
1587 Provider::OpenAI,
1588 None,
1589 Some(&preferred_realm),
1590 true,
1591 )
1592 .expect("candidates resolve");
1593
1594 assert_eq!(candidates[0].auth_binding.realm.as_str(), "dev");
1595 assert_eq!(candidates[0].auth_binding.binding.as_str(), "openai_oauth");
1596 assert_eq!(
1597 candidates.last().unwrap().auth_binding.realm.as_str(),
1598 "env_default"
1599 );
1600 }
1601
1602 #[test]
1603 fn realm_config_section_serde_empty() {
1604 let section = RealmConfigSection::default();
1605 let s = serde_json::to_string(§ion).unwrap();
1606 assert_eq!(s, "{}");
1608 }
1609
1610 #[test]
1611 fn realm_config_section_serde_populated() {
1612 let toml_input = r#"
1616default_binding = "default_openai"
1617
1618[backend.openai_default]
1619provider = "openai"
1620backend_kind = "openai_api"
1621base_url = "https://api.openai.com"
1622
1623[auth.openai_api_key]
1624provider = "openai"
1625auth_method = "api_key"
1626source = { kind = "env", env = "OPENAI_API_KEY" }
1627
1628[binding.default_openai]
1629backend_profile = "openai_default"
1630auth_profile = "openai_api_key"
1631default_model = "gpt-5.1"
1632"#;
1633 let section: RealmConfigSection = toml::from_str(toml_input).unwrap();
1634 assert_eq!(section.backend.len(), 1);
1635 assert_eq!(section.auth.len(), 1);
1636 assert_eq!(section.binding.len(), 1);
1637 assert_eq!(section.default_binding.as_deref(), Some("default_openai"));
1638 assert_eq!(
1639 section.backend["openai_default"].base_url.as_deref(),
1640 Some("https://api.openai.com"),
1641 );
1642 }
1643}