Skip to main content

meerkat_core/
provider.rs

1//! Provider enumeration shared across interfaces.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5
6/// Supported LLM providers.
7///
8/// `JsonSchema` is derived unconditionally (schemars is a non-optional
9/// meerkat-core dependency): config-owned types such as
10/// [`crate::config::CustomModelConfig`] embed the typed provider directly and
11/// derive their schemas without the `schema` feature.
12#[derive(
13    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
14)]
15#[serde(rename_all = "snake_case")]
16pub enum Provider {
17    Anthropic,
18    // `rename_all = "snake_case"` mangles `OpenAI` into `"open_a_i"`, which
19    // diverges from the canonical `as_str()` name `"openai"` that every other
20    // seam (and durable data) uses. Pin the canonical wire/schema name on the
21    // variant so the derived `Serialize`/`Deserialize` and the generated
22    // `schemars` schema all agree on `"openai"`. The alias is read-only for
23    // durable pre-0.7 session metadata; serialization remains canonical.
24    #[serde(rename = "openai", alias = "open_a_i")]
25    OpenAI,
26    Gemini,
27    SelfHosted,
28    Other,
29}
30
31impl Provider {
32    /// Map a provider name to a Provider enum.
33    pub fn from_name(name: &str) -> Self {
34        match name {
35            "anthropic" => Self::Anthropic,
36            "openai" => Self::OpenAI,
37            "gemini" => Self::Gemini,
38            "self_hosted" => Self::SelfHosted,
39            _ => Self::Other,
40        }
41    }
42
43    /// Parse a provider name strictly (only canonical lowercase names).
44    /// Returns `None` for unrecognized strings instead of falling back to `Other`.
45    pub fn parse_strict(name: &str) -> Option<Self> {
46        match name {
47            "anthropic" => Some(Self::Anthropic),
48            "openai" => Some(Self::OpenAI),
49            "gemini" => Some(Self::Gemini),
50            "self_hosted" => Some(Self::SelfHosted),
51            _ => None,
52        }
53    }
54
55    /// Return the canonical string representation.
56    pub fn as_str(&self) -> &'static str {
57        match self {
58            Self::Anthropic => "anthropic",
59            Self::OpenAI => "openai",
60            Self::Gemini => "gemini",
61            Self::SelfHosted => "self_hosted",
62            Self::Other => "other",
63        }
64    }
65
66    /// All concrete (non-Other) providers.
67    pub const ALL_CONCRETE: &'static [Provider] = &[
68        Provider::Anthropic,
69        Provider::OpenAI,
70        Provider::Gemini,
71        Provider::SelfHosted,
72    ];
73}
74
75/// Serde helper for seams that carry the provider as a plain `String` on the
76/// wire (e.g. `LiveProjectionSnapshot.provider_id`, whose JSON schema is
77/// `String`) but hold a typed [`Provider`] in memory.
78///
79/// Serialization matches the canonical [`Provider::as_str`] names — identical
80/// to the enum's own derived output now that [`Provider::OpenAI`] is pinned to
81/// `"openai"`. Deserialization is intentionally lenient (`Provider::from_name`,
82/// unknown → [`Provider::Other`]) so an opaque provider string carried by such
83/// a seam round-trips into the catch-all variant rather than failing closed —
84/// the leniency the plain-`String` carrier had before it was retyped.
85pub mod provider_canonical_str {
86    use super::Provider;
87    use serde::{Deserialize, Deserializer, Serialize, Serializer};
88
89    pub fn serialize<S>(value: &Provider, serializer: S) -> Result<S::Ok, S::Error>
90    where
91        S: Serializer,
92    {
93        value.as_str().serialize(serializer)
94    }
95
96    pub fn deserialize<'de, D>(deserializer: D) -> Result<Provider, D::Error>
97    where
98        D: Deserializer<'de>,
99    {
100        let name = String::deserialize(deserializer)?;
101        Ok(Provider::from_name(&name))
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::Provider;
108
109    #[test]
110    fn parse_strict_fails_closed_where_from_name_coerces_to_other() {
111        // Pins the two distinct provider-name boundaries the codebase relies on:
112        // `from_name` maps an unrecognized label to the typed `Other` variant
113        // (correct where a non-catalog provider is legitimate, e.g. a
114        // caller-supplied custom AgentLlmClient), whereas `parse_strict` returns
115        // None so fail-closed seams (e.g. catalog-default / session-create
116        // provider resolution) can surface a typed error instead of minting a
117        // catalog identity from an arbitrary string.
118        assert_eq!(
119            Provider::from_name("totally-unknown-provider"),
120            Provider::Other
121        );
122        assert_eq!(Provider::parse_strict("totally-unknown-provider"), None);
123        // Canonical names still resolve through the strict path.
124        assert_eq!(
125            Provider::parse_strict("anthropic"),
126            Some(Provider::Anthropic)
127        );
128        assert_eq!(Provider::parse_strict("openai"), Some(Provider::OpenAI));
129    }
130
131    #[test]
132    fn provider_deserializes_legacy_openai_tag() -> Result<(), serde_json::Error> {
133        let provider: Provider = serde_json::from_str("\"open_a_i\"")?;
134        assert_eq!(provider, Provider::OpenAI);
135        assert_eq!(serde_json::to_string(&provider)?, "\"openai\"");
136        Ok(())
137    }
138}