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