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