Skip to main content

meerkat_core/
connection.rs

1//! Realm-scoped connection contracts: backend profiles, auth profiles,
2//! provider bindings, and the ingestion wrapper `RealmConfigSection`.
3//!
4//! This module owns the cross-cutting runtime shapes used by sessions,
5//! factories, and surfaces. Provider-runtime-side typed enums
6//! (`OpenAiBackendKind`, `AnthropicAuthMethod`, etc.) live in
7//! [`crate::provider_matrix`]. Runtime config still carries `backend_kind` /
8//! `auth_method` as strings until they are normalized at the provider-runtime
9//! catalog boundary.
10
11use 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// ---------------------------------------------------------------------
43// Runtime shapes (what providers/surfaces consume at runtime)
44// ---------------------------------------------------------------------
45
46/// Error returned when a realm/binding/profile slug fails validation.
47#[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/// Session-facing reference to a binding inside a realm.
121///
122/// `AuthBindingRef` is purely structural — it does NOT carry a `"realm:binding"`
123/// string form. Wave-b deleted `parse` and `Display` so that no code path
124/// accidentally ferries the opaque join through the runtime. CLI input that
125/// arrives as `"realm:binding[:profile]"` must be split at the CLI boundary
126/// and constructed field-by-field.
127#[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/// Backend profile: where requests go and which backend contract applies.
145#[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/// Auth profile: how credentials are obtained, refreshed, constrained.
158#[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/// Where credentials come from.
172#[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    /// Binding-scoped credential material stored in the configured
180    /// [`TokenStore`](crate::auth::TokenStore). The storage key is the
181    /// resolved typed binding identity (`realm`, `binding`), not a
182    /// second free-form profile string.
183    ManagedStore,
184    Env {
185        env: String,
186        /// Ordered fallback env var names consulted when `env` is
187        /// unset. Used for providers with multiple well-known names
188        /// (e.g. Gemini falls back to `GOOGLE_API_KEY` when
189        /// `GEMINI_API_KEY` is absent). The resolver's RKAT_*-prefix
190        /// precedence applies to each name in turn.
191        #[serde(default, skip_serializing_if = "Vec::is_empty")]
192        fallback: Vec<String>,
193    },
194    ExternalResolver {
195        handle: String,
196    },
197    PlatformDefault,
198    /// External command that prints a bearer token on stdout. Reference:
199    /// Codex `external_bearer.rs:17-157`. The runner lives in
200    /// `meerkat-client/src/auth_store/command.rs`.
201    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        /// Timeout for the subprocess in milliseconds.
210        #[serde(default = "default_command_timeout_ms")]
211        timeout_ms: u64,
212        /// Optional cached-token lifetime. `None` disables caching.
213        #[serde(default, skip_serializing_if = "Option::is_none")]
214        refresh_interval_ms: Option<u64>,
215    },
216    /// Read credentials from an inherited file descriptor (Claude Code
217    /// pattern for sandboxed host-injected tokens).
218    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/// Policy overrides carried on a binding.
254#[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/// A binding is what sessions actually refer to: one backend + one auth
266/// profile, plus policy and an optional default model.
267#[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/// Realm-scoped set of backends, auth profiles, and bindings.
280///
281/// Produced by [`RealmConnectionSet::from_config`] from a
282/// [`RealmConfigSection`] ingested from TOML.
283#[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/// Fully resolved connection target selected from config-owned identity
295/// policy. Surfaces should use this instead of inventing realm or binding
296/// defaults locally.
297#[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
347/// Resolve a connection target from config-owned identity facts.
348///
349/// `explicit_realm` / `explicit_binding` are request atoms, not defaults.
350/// When either is absent, selection falls back to the preferred realm and
351/// that realm's `default_binding`, then to the configured `default` realm.
352/// Provider-shaped binding names and hard-coded realm names must not be
353/// encoded by REST/RPC/SDK surfaces.
354pub 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
434/// Resolve an explicit [`AuthBindingRef`] or the configured default target for
435/// the selected provider. This is the shared factory/runtime path for
436/// auth-binding-less provider resolution.
437pub 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
552/// Resolve ordered connection candidates for an omitted `auth_binding`.
553///
554/// The returned order is the shared "best available" policy used by all
555/// factory-backed surfaces:
556/// 1. configured provider binding in the preferred realm
557/// 2. configured provider binding in the `default` realm
558/// 3. configured provider binding in any remaining realm
559/// 4. synthetic env-var fallback when allowed
560///
561/// Within a realm, `default_binding` wins when it resolves to the requested
562/// provider, then `default_<provider>`, then a single unambiguous provider
563/// binding. Explicit `auth_binding` still resolves to one strict target.
564pub 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    /// Validate and materialize a realm connection set from its config
686    /// section. Normalizes provider strings into the typed
687    /// [`Provider`] enum and verifies that every binding references
688    /// existing backend and auth profiles whose providers agree.
689    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 &section.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            // id uniqueness within a single BTreeMap key space is
705            // guaranteed by the map itself; no extra check needed.
706            backends.insert(id.clone(), backend);
707        }
708
709        let mut auth_profiles: BTreeMap<String, AuthProfile> = BTreeMap::new();
710        for (id, cfg) in &section.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 &section.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    /// Synthesize a default [`RealmConnectionSet`] for a given provider,
759    /// sourcing credentials from a well-known env var. Used by surface
760    /// factories when no explicit realm config exists but the user has
761    /// set `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GEMINI_API_KEY` in
762    /// the environment. OpenAI also supports an Azure env envelope:
763    /// `AZURE_OPENAI_API_KEY` plus `AZURE_OPENAI_ENDPOINT` synthesizes the
764    /// `azure_openai` backend instead of public OpenAI when no public OpenAI
765    /// key is present. The synthesized realm is consumed by the same
766    /// `ProviderRuntimeRegistry` path as explicit realms, so env-var auth and
767    /// realm-config auth share one resolution pipeline.
768    ///
769    /// Returns a realm with id `"env_default"` containing one binding
770    /// `"default"` pointing at:
771    /// - BackendProfile `"default"` with the provider's default
772    ///   backend_kind and base_url=None (provider client uses its default).
773    /// - AuthProfile `"default"` with `source = Env { env: <ENV_VAR> }` and
774    ///   the provider-specific env auth method.
775    ///
776    /// The ENV_VAR name is per-provider:
777    /// - Anthropic: `ANTHROPIC_API_KEY`
778    /// - OpenAI:   `OPENAI_API_KEY`
779    /// - Azure OpenAI: `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`
780    /// - Google:   `GEMINI_API_KEY`
781    ///
782    /// Callers should also honor `RKAT_*`-prefixed overrides via
783    /// `ResolverEnvironment::with_process_env()`; that lookup is applied
784    /// inside the registry's resolve path when it reads the env source.
785    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    /// Testable variant of [`Self::synthesize_env_default`] that lets callers
790    /// inject the env lookup used to select the OpenAI public-vs-Azure default.
791    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    /// Synthesize a default realm with an inline secret instead of an env
800    /// lookup. Used when callers have already read the api key from a
801    /// config file (legacy credential-map path).
802    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    /// Resolve a binding by id. Returns the binding plus its referenced
860    /// backend and auth profiles.
861    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    /// Resolve a typed auth binding reference. `AuthBindingRef.profile`, when
881    /// present, overrides the binding's configured auth profile while keeping
882    /// the binding's backend and policy authoritative.
883    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/// Validation / reference-resolution errors for a realm connection set.
911///
912/// The plan originally listed a `DuplicateId(String)` variant; it's been
913/// omitted because `RealmConfigSection` uses `BTreeMap<String, ...>` for
914/// backends/auth/bindings, so duplicate ids within one category are
915/// impossible at ingestion time. Cross-category id sharing is harmless
916/// (lookups are category-keyed). If a future code path constructs a
917/// `RealmConfigSection` programmatically and needs duplicate detection,
918/// add the variant back alongside the check.
919#[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// ---------------------------------------------------------------------
940// Ingestion shapes (what TOML / config files deserialize into)
941// ---------------------------------------------------------------------
942
943/// Ingestion wrapper for `[realm.<id>.*]` TOML tables.
944///
945/// The singular nouns `backend`/`auth`/`binding` match TOML dotted-key
946/// notation (`[realm.dev.backend.openai_default]`) so that one `.backend.X`
947/// table becomes one entry in the `backend` map.
948#[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    /// Programmatic constructor for a realm populated from per-provider
963    /// inline api keys. Used by surfaces (notably the WASM browser
964    /// runtime) that receive credentials as plain strings at bootstrap
965    /// and need to translate them into the realm-based config shape
966    /// consumed by `AgentFactory::build_agent`.
967    ///
968    /// For each (provider, secret) pair, emits:
969    ///   - a `BackendProfileConfig { provider, backend_kind: "<p>_api" }`
970    ///   - an `AuthProfileConfig` with `CredentialSourceSpec::InlineSecret`
971    ///   - a `ProviderBindingConfig` wiring the two
972    ///
973    /// The first provider in the input list becomes the
974    /// `default_binding` so that build_agent's auth_binding-less
975    /// code path can resolve through this realm. Plan §6.10 replacement
976    /// for the deleted `ProviderSettings.api_keys` map.
977    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/// Serialized backend profile (pre-normalization).
1153#[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/// Serialized auth profile (pre-normalization).
1165#[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/// Serialized binding (pre-normalization).
1178#[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", &section).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", &section).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(&section).unwrap();
1606        // All maps empty + no default_binding → empty object.
1607        assert_eq!(s, "{}");
1608    }
1609
1610    #[test]
1611    fn realm_config_section_serde_populated() {
1612        // `default_binding` appears BEFORE any section header so that TOML
1613        // treats it as a top-level field rather than a key inside the last
1614        // subsection.
1615        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}