Skip to main content

zeroclaw_config/
providers.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use zeroclaw_macros::Configurable;
4
5use super::schema::{
6    Ai21ModelProviderConfig, AihubmixModelProviderConfig, AnthropicModelProviderConfig,
7    AnyscaleModelProviderConfig, ArceeModelProviderConfig, AstraiModelProviderConfig,
8    AtomicChatModelProviderConfig, AvianModelProviderConfig, AzureModelProviderConfig,
9    BaichuanModelProviderConfig, BasetenModelProviderConfig, BedrockModelProviderConfig,
10    CerebrasModelProviderConfig, CloudflareModelProviderConfig, CohereModelProviderConfig,
11    CopilotModelProviderConfig, CustomModelProviderConfig, DeepinfraModelProviderConfig,
12    DeepmystModelProviderConfig, DeepseekModelProviderConfig, DoubaoModelProviderConfig,
13    FeatherlessModelProviderConfig, FireworksModelProviderConfig, FriendliModelProviderConfig,
14    GeminiCliModelProviderConfig, GeminiModelProviderConfig, GithubModelsModelProviderConfig,
15    GlmModelProviderConfig, GroqModelProviderConfig, HuggingfaceModelProviderConfig,
16    HunyuanModelProviderConfig, HyperbolicModelProviderConfig, InceptionModelProviderConfig,
17    KiloCliModelProviderConfig, KiloModelProviderConfig, LambdaAiModelProviderConfig,
18    LeptonModelProviderConfig, LitellmModelProviderConfig, LlamacppModelProviderConfig,
19    LmstudioModelProviderConfig, MinimaxModelProviderConfig, MistralModelProviderConfig,
20    ModelProviderConfig, MoonshotModelProviderConfig, MorphModelProviderConfig,
21    NebiusModelProviderConfig, NovitaModelProviderConfig, NscaleModelProviderConfig,
22    NvidiaModelProviderConfig, OllamaModelProviderConfig, OpenAIModelProviderConfig,
23    OpenRouterModelProviderConfig, OpencodeModelProviderConfig, OsaurusModelProviderConfig,
24    OvhModelProviderConfig, PerplexityModelProviderConfig, QianfanModelProviderConfig,
25    QwenModelProviderConfig, RekaModelProviderConfig, SambanovaModelProviderConfig,
26    SglangModelProviderConfig, SiliconflowModelProviderConfig, StepfunModelProviderConfig,
27    SyntheticModelProviderConfig, TelnyxModelProviderConfig, TogetherModelProviderConfig,
28    UpstageModelProviderConfig, VeniceModelProviderConfig, VercelModelProviderConfig,
29    VllmModelProviderConfig, XaiModelProviderConfig, YiModelProviderConfig, ZaiModelProviderConfig,
30};
31use super::schema::{
32    AssemblyAiTranscriptionProviderConfig, DeepgramTranscriptionProviderConfig,
33    GoogleTranscriptionProviderConfig, GroqTranscriptionProviderConfig,
34    LocalWhisperTranscriptionProviderConfig, OpenAiTranscriptionProviderConfig,
35};
36use super::schema::{
37    EdgeTtsProviderConfig, ElevenLabsTtsProviderConfig, GoogleTtsProviderConfig,
38    OpenAITtsProviderConfig, PiperTtsProviderConfig, TtsProviderConfig as TtsBaseConfig,
39};
40
41// ── Per-category typed alias-ref newtypes ────────────────────────────────
42//
43// Every per-agent provider field is a reference into a specific configured
44// `[providers.<category>.<type>.<alias>]` (or `[channels.<type>.<alias>]`)
45// entry. The newtype carries the category at the type level — readers know
46// `agent.tts_provider: TtsProviderRef` is a TTS-provider reference, not a
47// free string, just by looking at the field declaration.
48//
49// `#[serde(transparent)]` keeps the on-disk TOML shape identical to the
50// previous `String` field. `Deref<Target = str>` and `AsRef<str>` keep
51// every `.is_empty()` / `.split_once('.')` / `.eq_ignore_ascii_case` /
52// `&value[..]` consumer working unchanged. Assignment from a string literal
53// goes through `.into()` (`From<&str>` / `From<String>`).
54//
55// Validation that each non-empty ref resolves to a configured alias lives
56// in `Config::validate()` (see `agent.tts_provider` / `agent.transcription_provider`
57// blocks in schema.rs); the newtype's job is to encode the *category* in
58// the type, not the existence — both layers reinforce each other.
59
60#[macro_export]
61macro_rules! define_provider_ref {
62    ($name:ident, $category_doc:literal) => {
63        #[doc = concat!("Reference to a configured `[", $category_doc, ".<type>.<alias>]` entry.")]
64        ///
65        /// Empty value means "no preference" (opt-out). Non-empty values must
66        /// resolve to a configured alias; `Config::validate()` enforces this.
67        #[derive(
68            Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
69        )]
70        #[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
71        #[serde(transparent)]
72        pub struct $name(pub String);
73
74        impl $name {
75            #[must_use]
76            pub fn new(value: impl Into<String>) -> Self {
77                Self(value.into())
78            }
79
80            #[must_use]
81            pub fn as_str(&self) -> &str {
82                &self.0
83            }
84
85            #[must_use]
86            pub fn is_empty(&self) -> bool {
87                self.0.is_empty()
88            }
89
90            #[must_use]
91            pub fn into_inner(self) -> String {
92                self.0
93            }
94        }
95
96        impl std::fmt::Display for $name {
97            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98                std::fmt::Display::fmt(&self.0, f)
99            }
100        }
101
102        impl std::ops::Deref for $name {
103            type Target = str;
104            fn deref(&self) -> &str {
105                &self.0
106            }
107        }
108
109        impl AsRef<str> for $name {
110            fn as_ref(&self) -> &str {
111                &self.0
112            }
113        }
114
115        impl From<String> for $name {
116            fn from(v: String) -> Self {
117                Self(v)
118            }
119        }
120
121        impl From<&str> for $name {
122            fn from(v: &str) -> Self {
123                Self(v.to_string())
124            }
125        }
126
127        impl From<$name> for String {
128            fn from(v: $name) -> Self {
129                v.0
130            }
131        }
132
133        impl PartialEq<str> for $name {
134            fn eq(&self, other: &str) -> bool {
135                self.0 == other
136            }
137        }
138
139        impl PartialEq<&str> for $name {
140            fn eq(&self, other: &&str) -> bool {
141                self.0 == *other
142            }
143        }
144
145        impl PartialEq<String> for $name {
146            fn eq(&self, other: &String) -> bool {
147                &self.0 == other
148            }
149        }
150    };
151}
152
153define_provider_ref!(ModelProviderRef, "providers.models");
154define_provider_ref!(TtsProviderRef, "providers.tts");
155define_provider_ref!(TranscriptionProviderRef, "providers.transcription");
156define_provider_ref!(ChannelRef, "channels");
157
158/// Hard ceiling on `providers.models.<alias>.fallback` chain depth. The cycle
159/// guard only bounds chains that loop; a long acyclic chain would otherwise
160/// recurse one stack frame per alias at config-load and build time, turning a
161/// pathological config into a startup stack overflow. Both the validation walk
162/// and the runtime build walk stop descending past this depth and prune the
163/// rest of the branch.
164pub const MAX_FALLBACK_DEPTH: usize = 3;
165
166/// Macro that expands to a single source of truth for the per-provider-type
167/// slot list on `ModelProviders`. Every helper that needs to walk every slot
168/// (`find`, `iter_entries`, `is_empty`, etc.) goes through this
169/// macro so adding a new model_provider type is a one-line addition here, not a
170/// shotgun edit across multiple helpers.
171///
172/// Each row is `(field_ident, provider_type_str, FamilyConfigType)`. The
173/// `provider_type_str` is the canonical TOML outer key, identical to the
174/// field name with hyphens forbidden (the schema uses underscores).
175///
176/// Exported so that downstream crates (notably `zeroclaw-providers`) can
177/// drive their own dispatch from the same single source of truth — adding
178/// a family is one row here and one trait impl in providers; missing the
179/// impl fails to compile when downstream macro consumers expand.
180#[macro_export]
181macro_rules! for_each_model_provider_slot {
182    ($mac:ident) => {
183        $mac! {
184            (openai, "openai", OpenAIModelProviderConfig),            (azure, "azure", AzureModelProviderConfig),
185            (anthropic, "anthropic", AnthropicModelProviderConfig),            (moonshot, "moonshot", MoonshotModelProviderConfig),
186            (qwen, "qwen", QwenModelProviderConfig),
187            (glm, "glm", GlmModelProviderConfig),
188            (minimax, "minimax", MinimaxModelProviderConfig),
189            (zai, "zai", ZaiModelProviderConfig),
190            (doubao, "doubao", DoubaoModelProviderConfig),
191            (yi, "yi", YiModelProviderConfig),
192            (hunyuan, "hunyuan", HunyuanModelProviderConfig),
193            (qianfan, "qianfan", QianfanModelProviderConfig),
194            (baichuan, "baichuan", BaichuanModelProviderConfig),
195            (openrouter, "openrouter", OpenRouterModelProviderConfig),
196            (ollama, "ollama", OllamaModelProviderConfig),
197            (gemini, "gemini", GeminiModelProviderConfig),
198            (gemini_cli, "gemini_cli", GeminiCliModelProviderConfig),
199            (bedrock, "bedrock", BedrockModelProviderConfig),
200            (telnyx, "telnyx", TelnyxModelProviderConfig),
201            (together, "together", TogetherModelProviderConfig),
202            (fireworks, "fireworks", FireworksModelProviderConfig),
203            (groq, "groq", GroqModelProviderConfig),
204            (mistral, "mistral", MistralModelProviderConfig),
205            (deepseek, "deepseek", DeepseekModelProviderConfig),
206            (atomic_chat, "atomic_chat", AtomicChatModelProviderConfig),
207            (cohere, "cohere", CohereModelProviderConfig),
208            (perplexity, "perplexity", PerplexityModelProviderConfig),
209            (xai, "xai", XaiModelProviderConfig),
210            (cerebras, "cerebras", CerebrasModelProviderConfig),
211            (sambanova, "sambanova", SambanovaModelProviderConfig),
212            (hyperbolic, "hyperbolic", HyperbolicModelProviderConfig),
213            (deepinfra, "deepinfra", DeepinfraModelProviderConfig),
214            (huggingface, "huggingface", HuggingfaceModelProviderConfig),
215            (ai21, "ai21", Ai21ModelProviderConfig),
216            (reka, "reka", RekaModelProviderConfig),
217            (baseten, "baseten", BasetenModelProviderConfig),
218            (nscale, "nscale", NscaleModelProviderConfig),
219            (anyscale, "anyscale", AnyscaleModelProviderConfig),
220            (nebius, "nebius", NebiusModelProviderConfig),
221            (friendli, "friendli", FriendliModelProviderConfig),
222            (stepfun, "stepfun", StepfunModelProviderConfig),
223            (aihubmix, "aihubmix", AihubmixModelProviderConfig),
224            (siliconflow, "siliconflow", SiliconflowModelProviderConfig),
225            (astrai, "astrai", AstraiModelProviderConfig),
226            (avian, "avian", AvianModelProviderConfig),
227            (deepmyst, "deepmyst", DeepmystModelProviderConfig),
228            (venice, "venice", VeniceModelProviderConfig),
229            (novita, "novita", NovitaModelProviderConfig),
230            (nvidia, "nvidia", NvidiaModelProviderConfig),
231            (vercel, "vercel", VercelModelProviderConfig),
232            (cloudflare, "cloudflare", CloudflareModelProviderConfig),
233            (ovh, "ovh", OvhModelProviderConfig),
234            (copilot, "copilot", CopilotModelProviderConfig),
235            (lmstudio, "lmstudio", LmstudioModelProviderConfig),
236            (llamacpp, "llamacpp", LlamacppModelProviderConfig),
237            (sglang, "sglang", SglangModelProviderConfig),
238            (vllm, "vllm", VllmModelProviderConfig),
239            (osaurus, "osaurus", OsaurusModelProviderConfig),
240            (litellm, "litellm", LitellmModelProviderConfig),
241            (lepton, "lepton", LeptonModelProviderConfig),
242            (morph, "morph", MorphModelProviderConfig),
243            (github_models, "github_models", GithubModelsModelProviderConfig),
244            (upstage, "upstage", UpstageModelProviderConfig),
245            (featherless, "featherless", FeatherlessModelProviderConfig),
246            (arcee, "arcee", ArceeModelProviderConfig),
247            (lambda_ai, "lambda_ai", LambdaAiModelProviderConfig),
248            (inception, "inception", InceptionModelProviderConfig),
249            (synthetic, "synthetic", SyntheticModelProviderConfig),
250            (opencode, "opencode", OpencodeModelProviderConfig),
251            (kilocli, "kilocli", KiloCliModelProviderConfig),
252            (kilo, "kilo", KiloModelProviderConfig),
253            (custom, "custom", CustomModelProviderConfig),
254        }
255    };
256}
257
258macro_rules! emit_model_providers_struct {
259    ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
260        /// Typed model provider container — one slot per canonical model_provider type.
261        ///
262        /// Replaces the `HashMap<String, HashMap<String, ModelProviderConfig>>`
263        /// with a typed struct so each family's per-alias map carries its own
264        /// typed config (with the family's `*Endpoint` enum and family-specific
265        /// extras visible at the type level).
266        ///
267        /// TOML shape is preserved byte-identical: each named field deserializes
268        /// from the same `[providers.models.<type>.<alias>]` block as before.
269        ///
270        /// Adding a new model_provider family means: define the typed config in
271        /// `schema.rs`, then add one row to `for_each_model_provider_slot!`,
272        /// and every helper picks up the new slot automatically.
273        #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
274        #[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
275        #[prefix = "providers.models"]
276        pub struct ModelProviders {
277            $(
278                #[serde(default, skip_serializing_if = "HashMap::is_empty")]
279                #[nested]
280                pub $field: HashMap<String, $cfg_ty>,
281            )+
282        }
283    };
284}
285for_each_model_provider_slot!(emit_model_providers_struct);
286
287impl ModelProviders {
288    /// Iterate every entry across every typed slot, yielding
289    /// `(provider_type, alias, &base)` triples. Use this when consumer code
290    /// needs to walk every model model_provider entry without caring about family.
291    ///
292    /// Materializes through a `Vec` rather than chaining iterators directly:
293    /// with ~60 typed slots the deeply-nested `Chain<Chain<...>>` type blows
294    /// up rustc's `Freeze` trait-resolution recursion limit. The collection
295    /// cost is negligible (entries are sparse — most slots are empty in any
296    /// real config). Returned as `impl Iterator` so call sites can chain
297    /// `.next()`, `.filter_map()`, etc. without changes.
298    pub fn iter_entries(&self) -> impl Iterator<Item = (&'static str, &str, &ModelProviderConfig)> {
299        let mut out: Vec<(&'static str, &str, &ModelProviderConfig)> = Vec::new();
300        macro_rules! emit_iter {
301            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
302                $(
303                    for (alias, cfg) in &self.$field {
304                        out.push(($type_str, alias.as_str(), &cfg.base));
305                    }
306                )+
307            };
308        }
309        for_each_model_provider_slot!(emit_iter);
310        out.into_iter()
311    }
312
313    /// Iterate every entry mutably across every typed slot.
314    pub fn iter_entries_mut(
315        &mut self,
316    ) -> impl Iterator<Item = (&'static str, &str, &mut ModelProviderConfig)> {
317        let mut out: Vec<(&'static str, &str, &mut ModelProviderConfig)> = Vec::new();
318        macro_rules! emit_iter_mut {
319            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
320                $(
321                    for (alias, cfg) in self.$field.iter_mut() {
322                        out.push(($type_str, alias.as_str(), &mut cfg.base));
323                    }
324                )+
325            };
326        }
327        for_each_model_provider_slot!(emit_iter_mut);
328        out.into_iter()
329    }
330
331    /// Resolve the family-default endpoint URI for `<family>.<alias>`. Returns
332    /// `None` when the family is single-endpoint, unknown, or the alias is
333    /// missing. Dispatch is generated by `for_each_model_provider_slot!`, so
334    /// adding a family without a `FamilyEndpoint` impl is a compile error.
335    pub fn resolved_endpoint_uri(&self, family: &str, alias: &str) -> Option<&'static str> {
336        use super::schema::FamilyEndpoint;
337        macro_rules! emit_endpoint {
338            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
339                match family {
340                    $( $type_str => self.$field.get(alias).and_then(|cfg| cfg.endpoint_uri()), )+
341                    _ => None,
342                }
343            };
344        }
345        for_each_model_provider_slot!(emit_endpoint)
346    }
347
348    /// Look up the shared base config for a given `<provider_type>.<alias>`
349    /// pair. Returns `None` when the family isn't recognized OR when
350    /// the alias doesn't exist in that family's typed slot.
351    pub fn find(&self, family: &str, alias: &str) -> Option<&ModelProviderConfig> {
352        macro_rules! emit_get {
353            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
354                match family {
355                    $( $type_str => self.$field.get(alias).map(|cfg| &cfg.base), )+
356                    _ => None,
357                }
358            };
359        }
360        for_each_model_provider_slot!(emit_get)
361    }
362
363    /// Resolve a name that is either a bare `<alias>` or a `<kind>.<alias>` pair
364    /// to its `(kind, alias, &config)`. A bare alias is matched across every
365    /// family; ambiguity (same alias under multiple kinds) returns `None` so the
366    /// caller can ask the user to qualify it. Registry-driven via
367    /// `for_each_model_provider_slot!`.
368    pub fn find_by_name(&self, name: &str) -> Option<(&'static str, String, &ModelProviderConfig)> {
369        if let Some((kind, alias)) = name.split_once('.') {
370            macro_rules! emit_dotted {
371                ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
372                    match kind {
373                        $( $type_str => self.$field.get(alias).map(|c| ($type_str, alias.to_string(), &c.base)), )+
374                        _ => None,
375                    }
376                };
377            }
378            return for_each_model_provider_slot!(emit_dotted);
379        }
380        let mut hit: Option<(&'static str, String, &ModelProviderConfig)> = None;
381        macro_rules! emit_bare {
382            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
383                $(
384                    if let Some(c) = self.$field.get(name) {
385                        if hit.is_some() {
386                            return None; // ambiguous across kinds
387                        }
388                        hit = Some(($type_str, name.to_string(), &c.base));
389                    }
390                )+
391            };
392        }
393        for_each_model_provider_slot!(emit_bare);
394        hit
395    }
396
397    /// Get-or-create the shared base config for a `<provider_type>.<alias>`
398    /// pair, returning a mutable reference. Used by tools that mutate
399    /// generic baseline fields (model, temperature, api_key) without caring
400    /// about the family's specific extras. Returns `None` for unknown
401    /// model_provider types.
402    pub fn ensure(&mut self, family: &str, alias: &str) -> Option<&mut ModelProviderConfig> {
403        macro_rules! emit_ensure {
404            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
405                match family {
406                    $(
407                        $type_str => Some(
408                            &mut self
409                                .$field
410                                .entry(alias.to_string())
411                                .or_default()
412                                .base,
413                        ),
414                    )+
415                    _ => None,
416                }
417            };
418        }
419        for_each_model_provider_slot!(emit_ensure)
420    }
421
422    /// True when `family`'s typed slot has at least one configured
423    /// alias entry. Returns `false` for unknown families.
424    pub fn contains_model_provider_type(&self, family: &str) -> bool {
425        macro_rules! emit_contains {
426            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
427                match family {
428                    $( $type_str => !self.$field.is_empty(), )+
429                    _ => false,
430                }
431            };
432        }
433        for_each_model_provider_slot!(emit_contains)
434    }
435
436    /// Iterate the alias keys for a given model_provider type. Returns an empty
437    /// iterator for unknown model_provider types.
438    pub fn aliases_of<'a>(&'a self, family: &str) -> Box<dyn Iterator<Item = &'a str> + 'a> {
439        macro_rules! emit_aliases {
440            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
441                match family {
442                    $( $type_str => Box::new(self.$field.keys().map(String::as_str)), )+
443                    _ => Box::new(std::iter::empty()),
444                }
445            };
446        }
447        for_each_model_provider_slot!(emit_aliases)
448    }
449
450    /// Canonical family slot names, straight from
451    /// `for_each_model_provider_slot!`. Use this to distinguish "unknown
452    /// family" from "known family, missing alias" in validation messages,
453    /// and to detect raw-TOML sections that deserialization silently drops.
454    #[must_use]
455    pub fn slot_names() -> &'static [&'static str] {
456        macro_rules! emit_slot_names {
457            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
458                &[$($type_str),+]
459            };
460        }
461        const NAMES: &[&str] = for_each_model_provider_slot!(emit_slot_names);
462        NAMES
463    }
464
465    /// Remove the entry for `<provider_type>.<alias>`, returning whether it
466    /// existed. Returns `false` for unknown families.
467    pub fn remove_alias(&mut self, family: &str, alias: &str) -> bool {
468        macro_rules! emit_remove {
469            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
470                match family {
471                    $( $type_str => self.$field.remove(alias).is_some(), )+
472                    _ => false,
473                }
474            };
475        }
476        for_each_model_provider_slot!(emit_remove)
477    }
478
479    /// True when no slot has any entry.
480    pub fn is_empty(&self) -> bool {
481        macro_rules! emit_is_empty {
482            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
483                $( self.$field.is_empty() && )+ true
484            };
485        }
486        for_each_model_provider_slot!(emit_is_empty)
487    }
488
489    /// Total number of (provider_type, alias) entries across all slots.
490    pub fn len(&self) -> usize {
491        macro_rules! emit_len {
492            ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
493                0 $( + self.$field.len() )+
494            };
495        }
496        for_each_model_provider_slot!(emit_len)
497    }
498}
499
500/// Typed TTS-provider container — one slot per TTS family. Mirrors
501/// `ModelProviders` but smaller (TTS has a closed set of 5 families:
502/// openai, elevenlabs, google, edge, piper). No catch-all needed.
503#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
504#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
505#[prefix = "providers.tts"]
506pub struct TtsProviders {
507    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
508    #[nested]
509    pub openai: HashMap<String, OpenAITtsProviderConfig>,
510    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
511    #[nested]
512    pub elevenlabs: HashMap<String, ElevenLabsTtsProviderConfig>,
513    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
514    #[nested]
515    pub google: HashMap<String, GoogleTtsProviderConfig>,
516    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
517    #[nested]
518    pub edge: HashMap<String, EdgeTtsProviderConfig>,
519    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
520    #[nested]
521    pub piper: HashMap<String, PiperTtsProviderConfig>,
522}
523
524impl TtsProviders {
525    /// Iterate every TTS entry across every typed slot, yielding
526    /// `(family, alias, &base)` triples.
527    pub fn iter_entries(
528        &self,
529    ) -> Box<dyn Iterator<Item = (&'static str, &str, &TtsBaseConfig)> + '_> {
530        Box::new(
531            std::iter::empty()
532                .chain(
533                    self.openai
534                        .iter()
535                        .map(|(a, c)| ("openai", a.as_str(), &c.base)),
536                )
537                .chain(
538                    self.elevenlabs
539                        .iter()
540                        .map(|(a, c)| ("elevenlabs", a.as_str(), &c.base)),
541                )
542                .chain(
543                    self.google
544                        .iter()
545                        .map(|(a, c)| ("google", a.as_str(), &c.base)),
546                )
547                .chain(self.edge.iter().map(|(a, c)| ("edge", a.as_str(), &c.base)))
548                .chain(
549                    self.piper
550                        .iter()
551                        .map(|(a, c)| ("piper", a.as_str(), &c.base)),
552                ),
553        )
554    }
555
556    /// Iterate every TTS entry mutably across every typed slot.
557    pub fn iter_entries_mut(
558        &mut self,
559    ) -> Box<dyn Iterator<Item = (&'static str, &str, &mut TtsBaseConfig)> + '_> {
560        Box::new(
561            std::iter::empty()
562                .chain(
563                    self.openai
564                        .iter_mut()
565                        .map(|(a, c)| ("openai", a.as_str(), &mut c.base)),
566                )
567                .chain(
568                    self.elevenlabs
569                        .iter_mut()
570                        .map(|(a, c)| ("elevenlabs", a.as_str(), &mut c.base)),
571                )
572                .chain(
573                    self.google
574                        .iter_mut()
575                        .map(|(a, c)| ("google", a.as_str(), &mut c.base)),
576                )
577                .chain(
578                    self.edge
579                        .iter_mut()
580                        .map(|(a, c)| ("edge", a.as_str(), &mut c.base)),
581                )
582                .chain(
583                    self.piper
584                        .iter_mut()
585                        .map(|(a, c)| ("piper", a.as_str(), &mut c.base)),
586                ),
587        )
588    }
589
590    /// True when no slot has any entry.
591    pub fn is_empty(&self) -> bool {
592        self.openai.is_empty()
593            && self.elevenlabs.is_empty()
594            && self.google.is_empty()
595            && self.edge.is_empty()
596            && self.piper.is_empty()
597    }
598}
599
600/// Typed transcription-provider container — one slot per STT family.
601/// Mirrors `ModelProviders` / `TtsProviders`. Closed set of 6 families:
602/// groq, openai, deepgram, assemblyai, google, local_whisper.
603#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
604#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
605#[prefix = "providers.transcription"]
606pub struct TranscriptionProviders {
607    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
608    #[nested]
609    pub groq: HashMap<String, GroqTranscriptionProviderConfig>,
610    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
611    #[nested]
612    pub openai: HashMap<String, OpenAiTranscriptionProviderConfig>,
613    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
614    #[nested]
615    pub deepgram: HashMap<String, DeepgramTranscriptionProviderConfig>,
616    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
617    #[nested]
618    pub assemblyai: HashMap<String, AssemblyAiTranscriptionProviderConfig>,
619    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
620    #[nested]
621    pub google: HashMap<String, GoogleTranscriptionProviderConfig>,
622    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
623    #[nested]
624    pub local_whisper: HashMap<String, LocalWhisperTranscriptionProviderConfig>,
625}
626
627impl TranscriptionProviders {
628    /// True when no slot has any entry.
629    pub fn is_empty(&self) -> bool {
630        self.groq.is_empty()
631            && self.openai.is_empty()
632            && self.deepgram.is_empty()
633            && self.assemblyai.is_empty()
634            && self.google.is_empty()
635            && self.local_whisper.is_empty()
636    }
637
638    /// Iterate every configured (family, alias) pair across all six slots.
639    pub fn iter_aliases(&self) -> impl Iterator<Item = (&'static str, &str)> {
640        let mut out: Vec<(&'static str, &str)> = Vec::new();
641        for k in self.groq.keys() {
642            out.push(("groq", k.as_str()));
643        }
644        for k in self.openai.keys() {
645            out.push(("openai", k.as_str()));
646        }
647        for k in self.deepgram.keys() {
648            out.push(("deepgram", k.as_str()));
649        }
650        for k in self.assemblyai.keys() {
651            out.push(("assemblyai", k.as_str()));
652        }
653        for k in self.google.keys() {
654            out.push(("google", k.as_str()));
655        }
656        for k in self.local_whisper.keys() {
657            out.push(("local_whisper", k.as_str()));
658        }
659        out.into_iter()
660    }
661}
662
663/// Top-level wrapper for every provider category. TOML root sees a
664/// single `[providers]` table with one sub-key per category:
665///
666/// ```toml
667/// [providers.models.anthropic.default]
668/// api_key = "..."
669///
670/// [providers.tts.openai.default]
671/// api_key = "..."
672///
673/// [providers.transcription.groq.default]
674/// api_key = "..."
675/// ```
676///
677/// Each category keeps its own typed-slot internals (so per-family
678/// endpoints and extras stay validated at the type level); this
679/// wrapper just gives them a shared top-level home.
680#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
681#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
682#[prefix = "providers"]
683pub struct Providers {
684    /// Model providers — `[providers.models.<type>.<alias>]`.
685    #[serde(default)]
686    #[nested]
687    pub models: ModelProviders,
688
689    /// Text-to-speech providers — `[providers.tts.<type>.<alias>]`.
690    #[serde(default)]
691    #[nested]
692    pub tts: TtsProviders,
693
694    /// Transcription / speech-to-text providers — `[providers.transcription.<type>.<alias>]`.
695    #[serde(default)]
696    #[nested]
697    pub transcription: TranscriptionProviders,
698}
699
700// ── Cost-rate wrappers ──────────────────────────────────────────────────────
701//
702// Same per-provider-type slot layout as the typed-provider wrappers above,
703// but the value type is the per-resource rate struct instead of the
704// per-alias provider config. Each subsection's TOML path mirrors its
705// `[providers.*]` counterpart with the trailing `<alias>` segment replaced
706// by the resource the rate prices (model id, voice id, etc.).
707//
708// DRY:
709//   - `ModelCostRatesByProvider` consumes the same `for_each_model_provider_slot!`
710//     macro as `ModelProviders`, so adding a new provider type updates
711//     both structs from a single edit.
712//   - `TtsCostRatesByProvider` and `TranscriptionCostRatesByProvider`
713//     mirror their `TtsProviders` / `TranscriptionProviders` slot lists
714//     by hand (those wrappers are themselves hand-rolled because the
715//     closed family lists were small enough to not warrant a macro).
716
717macro_rules! emit_model_cost_rates_struct {
718    ($(($field:ident, $type_str:literal, $cfg_ty:ty)),+ $(,)?) => {
719        /// `[cost.rates.providers.models.<type>.<model>]` — token-cost rates
720        /// per (provider type, model). One slot per provider type; each
721        /// slot is a `HashMap<model_id, ModelCostRates>`. The slot list
722        /// matches `ModelProviders` byte-for-byte (same source macro).
723        #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
724        #[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
725        #[prefix = "cost.rates.providers.models"]
726        pub struct ModelCostRatesByProvider {
727            $(
728                #[serde(default, skip_serializing_if = "HashMap::is_empty")]
729                #[nested]
730                #[resource_key]
731                pub $field: HashMap<String, super::schema::ModelCostRates>,
732            )+
733        }
734
735        impl ModelCostRatesByProvider {
736            /// Lookup rates by `(provider_type, model_id)`.
737            #[must_use]
738            pub fn get(
739                &self,
740                provider_type: &str,
741                model_id: &str,
742            ) -> Option<&super::schema::ModelCostRates> {
743                match provider_type {
744                    $(
745                        $type_str => self.$field.get(model_id),
746                    )+
747                    _ => None,
748                }
749            }
750
751            /// Iterate every priced model across every slot, yielding
752            /// `(provider_type, model_id, &rates)` triples. Mirrors
753            /// `ModelProviders::iter_entries` so callers can walk both
754            /// the providers and the rate sheet with the same loop shape.
755            pub fn iter_entries(
756                &self,
757            ) -> impl Iterator<Item = (&'static str, &str, &super::schema::ModelCostRates)> {
758                let mut out: Vec<(&'static str, &str, &super::schema::ModelCostRates)> = Vec::new();
759                $(
760                    for (model_id, rates) in &self.$field {
761                        out.push(($type_str, model_id.as_str(), rates));
762                    }
763                )+
764                out.into_iter()
765            }
766
767            /// True when no slot has any priced model.
768            pub fn is_empty(&self) -> bool {
769                $(self.$field.is_empty())&&+
770            }
771        }
772    };
773}
774for_each_model_provider_slot!(emit_model_cost_rates_struct);
775
776/// Slot list for TTS providers. Single source of truth shared between
777/// the typed-provider wrapper and the cost-rates wrapper — adding a TTS
778/// family is a one-line edit here.
779#[macro_export]
780macro_rules! for_each_tts_provider_slot {
781    ($mac:ident, $rate_ty:ty) => {
782        $mac! {
783            $rate_ty,
784            (openai, "openai"),
785            (elevenlabs, "elevenlabs"),
786            (google, "google"),
787            (edge, "edge"),
788            (piper, "piper"),
789        }
790    };
791}
792
793/// Slot list for transcription providers.
794#[macro_export]
795macro_rules! for_each_transcription_provider_slot {
796    ($mac:ident, $rate_ty:ty) => {
797        $mac! {
798            $rate_ty,
799            (groq, "groq"),
800            (openai, "openai"),
801            (deepgram, "deepgram"),
802            (assemblyai, "assemblyai"),
803            (google, "google"),
804            (local_whisper, "local_whisper"),
805        }
806    };
807}
808
809/// Collect the `$type_str` names out of a rate-typed slot macro
810/// (`for_each_tts_provider_slot!` / `for_each_transcription_provider_slot!`).
811/// The `$rate_ty` head is consumed and ignored; the macro exists so
812/// `slot_names()` derives from the same source the structs are built from.
813macro_rules! collect_rate_slot_names {
814    ($rate_ty:ty, $(($field:ident, $type_str:literal)),+ $(,)?) => {
815        &[$($type_str),+]
816    };
817}
818
819impl TtsProviders {
820    /// Canonical TTS family slot names, derived from
821    /// `for_each_tts_provider_slot!`.
822    #[must_use]
823    pub fn slot_names() -> &'static [&'static str] {
824        const NAMES: &[&str] = for_each_tts_provider_slot!(collect_rate_slot_names, ());
825        NAMES
826    }
827}
828
829impl TranscriptionProviders {
830    /// Canonical transcription family slot names, derived from
831    /// `for_each_transcription_provider_slot!`.
832    #[must_use]
833    pub fn slot_names() -> &'static [&'static str] {
834        const NAMES: &[&str] = for_each_transcription_provider_slot!(collect_rate_slot_names, ());
835        NAMES
836    }
837}
838
839/// Emit a `<Family>CostRatesByProvider` struct from a slot list. Used
840/// by both the TTS and transcription cost-rate wrappers — every field,
841/// every dispatch arm, every iter row expands from one slot list. No
842/// hand-typed `match "openai" => self.openai` tables anywhere.
843macro_rules! emit_simple_cost_rates_struct {
844    (
845        $struct_name:ident,
846        $rate_ty:ty,
847        $prefix:literal,
848        $resource_doc:literal,
849        $(($field:ident, $type_str:literal)),+ $(,)?
850    ) => {
851        #[doc = concat!("`", $prefix, ".<type>.<", $resource_doc, ">`")]
852        ///  — per-(provider type, resource) cost rates.
853        #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
854        #[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
855        #[prefix = $prefix]
856        pub struct $struct_name {
857            $(
858                #[serde(default, skip_serializing_if = "HashMap::is_empty")]
859                #[nested]
860                #[resource_key]
861                pub $field: HashMap<String, $rate_ty>,
862            )+
863        }
864
865        impl $struct_name {
866            /// Lookup rates by `(provider_type, resource_id)`.
867            #[must_use]
868            pub fn get(&self, provider_type: &str, resource_id: &str) -> Option<&$rate_ty> {
869                match provider_type {
870                    $($type_str => self.$field.get(resource_id),)+
871                    _ => None,
872                }
873            }
874
875            /// Iterate `(provider_type, resource_id, &rates)` across every
876            /// slot.
877            pub fn iter_entries(
878                &self,
879            ) -> impl Iterator<Item = (&'static str, &str, &$rate_ty)> {
880                let mut out: Vec<(&'static str, &str, &$rate_ty)> = Vec::new();
881                $(
882                    for (resource_id, rates) in &self.$field {
883                        out.push(($type_str, resource_id.as_str(), rates));
884                    }
885                )+
886                out.into_iter()
887            }
888
889            /// True when no slot has any priced resource.
890            pub fn is_empty(&self) -> bool {
891                $(self.$field.is_empty())&&+
892            }
893        }
894    };
895}
896
897macro_rules! emit_tts_cost_rates_struct {
898    ($rate_ty:ty, $($slot:tt),+ $(,)?) => {
899        emit_simple_cost_rates_struct! {
900            TtsCostRatesByProvider,
901            $rate_ty,
902            "cost.rates.providers.tts",
903            "voice",
904            $($slot),+
905        }
906    };
907}
908for_each_tts_provider_slot!(emit_tts_cost_rates_struct, super::schema::TtsCostRates);
909
910macro_rules! emit_transcription_cost_rates_struct {
911    ($rate_ty:ty, $($slot:tt),+ $(,)?) => {
912        emit_simple_cost_rates_struct! {
913            TranscriptionCostRatesByProvider,
914            $rate_ty,
915            "cost.rates.providers.transcription",
916            "model",
917            $($slot),+
918        }
919    };
920}
921for_each_transcription_provider_slot!(
922    emit_transcription_cost_rates_struct,
923    super::schema::TranscriptionCostRates
924);