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}