Skip to main content

liter_llm/provider/
mod.rs

1use std::borrow::Cow;
2use std::collections::{HashMap, HashSet};
3use std::sync::LazyLock;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::Deserialize;
7
8use crate::error::{LiterLlmError, Result};
9
10/// Return the current Unix epoch timestamp in seconds.
11///
12/// Used by provider transformers to populate the `created` field in
13/// OpenAI-compatible response objects. Falls back to `0` if the system
14/// clock is before the epoch (should never happen in practice).
15pub(crate) fn unix_timestamp_secs() -> u64 {
16    SystemTime::now()
17        .duration_since(UNIX_EPOCH)
18        .map(|d| d.as_secs())
19        .unwrap_or(0)
20}
21
22/// The streaming wire format a provider uses for its response stream.
23///
24/// Most providers use standard Server-Sent Events (SSE).  AWS Bedrock uses
25/// a proprietary binary EventStream framing.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum StreamFormat {
28    /// Standard Server-Sent Events (text/event-stream).
29    Sse,
30    /// AWS EventStream binary framing (application/vnd.amazon.eventstream).
31    AwsEventStream,
32}
33
34// Embed the generated providers registry at compile time.
35const PROVIDERS_JSON: &str = include_str!("../../schemas/providers.json");
36
37/// Lazy-initialised registry parsed from the embedded JSON.
38/// Stores a `Result` so that parse failures surface at call time rather than
39/// panicking the process (fix for the `.expect()` on LazyLock).
40static REGISTRY: LazyLock<std::result::Result<ProviderRegistry, String>> =
41    LazyLock::new(|| serde_json::from_str(PROVIDERS_JSON).map_err(|e| e.to_string()));
42
43/// Access the registry, returning an error if the embedded JSON was invalid.
44fn registry() -> Result<&'static ProviderRegistry> {
45    REGISTRY.as_ref().map_err(|e| LiterLlmError::ServerError {
46        message: format!("embedded schemas/providers.json is invalid: {e}"),
47    })
48}
49
50// ── Registry types (deserialised from providers.json) ────────────────────────
51
52#[derive(Debug, Deserialize)]
53struct ProviderRegistry {
54    providers: Vec<ProviderConfig>,
55    /// Set of complex provider names for O(1) lookup.
56    ///
57    /// Deserialized from a JSON array; converted to a `HashSet` for fast
58    /// membership tests in the hot `detect_provider` path.
59    #[serde(default, deserialize_with = "deserialize_hashset")]
60    complex_providers: HashSet<String>,
61}
62
63fn deserialize_hashset<'de, D>(deserializer: D) -> std::result::Result<HashSet<String>, D::Error>
64where
65    D: serde::Deserializer<'de>,
66{
67    let vec = Vec::<String>::deserialize(deserializer)?;
68    Ok(vec.into_iter().collect())
69}
70
71/// Static configuration for a single provider entry in providers.json.
72#[derive(Debug, Clone, Deserialize)]
73pub struct ProviderConfig {
74    pub name: String,
75    pub display_name: Option<String>,
76    pub base_url: Option<String>,
77    pub auth: Option<AuthConfig>,
78    pub endpoints: Option<Vec<String>>,
79    pub model_prefixes: Option<Vec<String>>,
80    /// Parameter key renaming for this provider.
81    ///
82    /// Each entry maps an OpenAI-spec field name (e.g. `"max_completion_tokens"`)
83    /// to the name this provider expects (e.g. `"max_tokens"`).  Applied
84    /// automatically by [`ConfigDrivenProvider::transform_request`].
85    pub(crate) param_mappings: Option<HashMap<String, String>>,
86}
87
88/// Auth scheme used by a provider.
89#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "kebab-case")]
91pub enum AuthType {
92    /// Standard `Authorization: Bearer <key>` header.
93    Bearer,
94    /// `x-api-key: <key>` header (also handles `"header"` and `"x-api-key"` aliases).
95    #[serde(alias = "header", alias = "x-api-key")]
96    ApiKey,
97    /// No authentication header required.
98    None,
99    /// Unrecognised auth scheme — falls back to bearer.
100    #[serde(other)]
101    Unknown,
102}
103
104/// Auth configuration block.
105#[derive(Debug, Clone, Deserialize)]
106pub struct AuthConfig {
107    #[serde(rename = "type")]
108    pub auth_type: AuthType,
109    pub env_var: Option<String>,
110}
111
112// ── Provider trait ───────────────────────────────────────────────────────────
113
114/// A provider defines how to reach an LLM API endpoint.
115pub trait Provider: Send + Sync {
116    /// Validate provider configuration at construction time.
117    ///
118    /// Called by [`DefaultClient::new`] immediately after the provider is
119    /// resolved.  Returning an error here surfaces misconfiguration early
120    /// (e.g. missing Azure `base_url`) rather than on the first request.
121    ///
122    /// The default implementation is a no-op; providers with required
123    /// configuration fields (like Azure) override this.
124    fn validate(&self) -> Result<()> {
125        Ok(())
126    }
127
128    /// Name of the environment variable that holds the API key for this provider.
129    ///
130    /// Returns `None` for providers that do not use an API key (e.g. auth type
131    /// `none`), or for providers whose key source is handled out-of-band (e.g.
132    /// AWS Bedrock credentials resolved via the AWS SDK).
133    ///
134    /// Used by [`DefaultClient::new`] to auto-load the API key from the
135    /// environment when `load_env` is enabled and no explicit key was provided.
136    fn env_var(&self) -> Option<&str> {
137        None
138    }
139
140    /// Provider name (e.g., "openai").
141    fn name(&self) -> &str;
142
143    /// Base URL (e.g., "https://api.openai.com/v1").
144    fn base_url(&self) -> &str;
145
146    /// Build the authorization header as `Some((header-name, header-value))`.
147    ///
148    /// Returns `None` when the provider requires no authentication header
149    /// (e.g. local models or providers with `auth: none`).  Callers must skip
150    /// inserting any header when `None` is returned.
151    ///
152    /// When `Some`, returns a static header name and a borrowed-or-owned value
153    /// to avoid allocating the header name string on every request.
154    fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)>;
155
156    /// Additional static headers required by this provider beyond the auth header.
157    ///
158    /// Most providers return an empty slice.  Use this for provider-mandated
159    /// headers like Anthropic's `anthropic-version`.
160    fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
161        &[]
162    }
163
164    /// Compute request-dependent headers based on the request body.
165    ///
166    /// Called by the client for each request. Use this for headers that
167    /// vary per-request, like Anthropic's `anthropic-beta` which depends
168    /// on whether thinking or hosted tools are enabled.
169    ///
170    /// The default implementation returns an empty vector.
171    fn dynamic_headers(&self, _body: &serde_json::Value) -> Vec<(String, String)> {
172        vec![]
173    }
174
175    /// Whether this provider matches a given model string.
176    fn matches_model(&self, model: &str) -> bool;
177
178    /// Strip any provider-routing prefix from a model name before sending it
179    /// in the request body.
180    ///
181    /// E.g. `"groq/llama3-70b"` → `"llama3-70b"`.
182    /// Returns the model name unchanged when no prefix is present.
183    fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
184        // Try "name/" prefix without allocating.
185        if let Some(rest) = model.strip_prefix(self.name())
186            && let Some(stripped) = rest.strip_prefix('/')
187        {
188            return stripped;
189        }
190        model
191    }
192
193    /// Path for chat completions endpoint.
194    fn chat_completions_path(&self) -> &str {
195        "/chat/completions"
196    }
197
198    /// Path for embeddings endpoint.
199    fn embeddings_path(&self) -> &str {
200        "/embeddings"
201    }
202
203    /// Path for list models endpoint.
204    fn models_path(&self) -> &str {
205        "/models"
206    }
207
208    /// Path for image generations endpoint.
209    fn image_generations_path(&self) -> &str {
210        "/images/generations"
211    }
212
213    /// Path for text-to-speech endpoint.
214    fn audio_speech_path(&self) -> &str {
215        "/audio/speech"
216    }
217
218    /// Path for audio transcription endpoint.
219    fn audio_transcriptions_path(&self) -> &str {
220        "/audio/transcriptions"
221    }
222
223    /// Path for content moderation endpoint.
224    fn moderations_path(&self) -> &str {
225        "/moderations"
226    }
227
228    /// Path for document reranking endpoint.
229    fn rerank_path(&self) -> &str {
230        "/rerank"
231    }
232
233    /// Path for the files management endpoint (e.g. POST /files, GET /files/{id}).
234    fn files_path(&self) -> &str {
235        "/files"
236    }
237
238    /// Path for the batches management endpoint (e.g. POST /batches, GET /batches/{id}).
239    fn batches_path(&self) -> &str {
240        "/batches"
241    }
242
243    /// Path for the responses endpoint (e.g. POST /responses).
244    fn responses_path(&self) -> &str {
245        "/responses"
246    }
247
248    /// Path for the web/document search endpoint.
249    fn search_path(&self) -> &str {
250        "/search"
251    }
252
253    /// Path for the OCR (optical character recognition) endpoint.
254    fn ocr_path(&self) -> &str {
255        "/ocr"
256    }
257
258    /// Whether streaming is supported.
259    #[allow(dead_code)] // reserved for future provider-capability checking
260    fn supports_streaming(&self) -> bool {
261        true
262    }
263
264    /// Transform the request body before sending, if needed.
265    fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
266        let _ = body;
267        Ok(())
268    }
269
270    /// Transform the raw response JSON before deserialization into canonical types.
271    ///
272    /// Providers returning non-OpenAI formats (Anthropic, Bedrock, Vertex) override
273    /// this to normalize their native response into OpenAI-compatible JSON.
274    /// The default implementation is a no-op (OpenAI-compatible responses pass through
275    /// unchanged).
276    fn transform_response(&self, _body: &mut serde_json::Value) -> Result<()> {
277        Ok(())
278    }
279
280    /// Build the full URL for a specific endpoint and model.
281    ///
282    /// Default: `{base_url}{endpoint_path}`.  Providers like Azure and Bedrock
283    /// override this to embed deployment names, model IDs, or query parameters
284    /// into the URL.
285    fn build_url(&self, endpoint_path: &str, _model: &str) -> String {
286        format!("{}{}", self.base_url(), endpoint_path)
287    }
288
289    /// Parse a single SSE event data string into a `ChatCompletionChunk`.
290    ///
291    /// Default: OpenAI format (straight JSON parse).
292    /// Anthropic and Vertex override for their native streaming event formats.
293    ///
294    /// The `[DONE]` sentinel is handled at the SSE parser level before this
295    /// method is called, so implementations do not need to check for it.
296    ///
297    /// Returns `Ok(Some(chunk))` for a successfully parsed event.
298    /// Returns `Ok(None)` to skip this event (continue reading the stream).
299    /// Returns `Err` when the event cannot be parsed.
300    fn parse_stream_event(&self, event_data: &str) -> Result<Option<crate::types::ChatCompletionChunk>> {
301        serde_json::from_str::<crate::types::ChatCompletionChunk>(event_data)
302            .map(Some)
303            .map_err(|e| LiterLlmError::Streaming {
304                message: format!("failed to parse SSE data: {e}"),
305            })
306    }
307
308    /// The streaming wire format this provider uses.
309    ///
310    /// Default: [`StreamFormat::Sse`].  Override for providers that use
311    /// non-SSE framing (e.g. AWS Bedrock EventStream).
312    fn stream_format(&self) -> StreamFormat {
313        StreamFormat::Sse
314    }
315
316    /// Build the full URL for a streaming request.
317    ///
318    /// Default: delegates to [`Provider::build_url`].  Providers whose
319    /// streaming endpoint differs from the non-streaming one (e.g. Bedrock
320    /// uses `/converse-stream` vs `/converse`) override this.
321    fn build_stream_url(&self, endpoint_path: &str, model: &str) -> String {
322        self.build_url(endpoint_path, model)
323    }
324
325    /// Compute dynamic signing headers for the outgoing request.
326    ///
327    /// Called by the client just before sending each request.  The default
328    /// implementation returns an empty vector (no extra signing required).
329    ///
330    /// Providers that use request-signing (e.g. AWS Bedrock with SigV4) override
331    /// this to return the computed `Authorization`, `x-amz-date`, and
332    /// `x-amz-security-token` headers.  The returned headers are merged with the
333    /// provider's static [`Provider::extra_headers`] before the request is sent.
334    ///
335    /// # Arguments
336    ///
337    /// - `method`: HTTP method string, e.g. `"POST"`.
338    /// - `url`: Full request URL including path and query string.
339    /// - `body`: Serialised request body bytes (used in the payload hash).
340    fn signing_headers(&self, method: &str, url: &str, body: &[u8]) -> Vec<(String, String)> {
341        let _ = (method, url, body);
342        vec![]
343    }
344}
345
346pub(crate) mod anthropic;
347pub(crate) mod azure;
348pub(crate) mod bedrock;
349pub(crate) mod cohere;
350pub mod custom;
351pub(crate) mod github_copilot;
352pub(crate) mod google_ai;
353pub(crate) mod mistral;
354pub(crate) mod vertex;
355
356// ── Built-in providers ───────────────────────────────────────────────────────
357
358/// Built-in OpenAI provider.
359pub(crate) struct OpenAiProvider;
360
361impl Provider for OpenAiProvider {
362    fn name(&self) -> &str {
363        "openai"
364    }
365
366    fn base_url(&self) -> &str {
367        "https://api.openai.com/v1"
368    }
369
370    fn env_var(&self) -> Option<&str> {
371        Some("OPENAI_API_KEY")
372    }
373
374    fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
375        Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
376    }
377
378    fn matches_model(&self, model: &str) -> bool {
379        model.starts_with("gpt-")
380            || model.starts_with("o1-")
381            || model.starts_with("o3-")
382            || model.starts_with("o4-")
383            || model == "o1"
384            || model == "o3"
385            || model == "o4"
386            || model.starts_with("dall-e-")
387            || model.starts_with("whisper-")
388            || model.starts_with("tts-")
389            || model.starts_with("text-embedding-")
390            || model.starts_with("chatgpt-")
391            || model.starts_with("openai/")
392    }
393
394    fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
395        model.strip_prefix("openai/").unwrap_or(model)
396    }
397}
398
399/// A generic OpenAI-compatible provider (configurable base_url + bearer auth).
400pub(crate) struct OpenAiCompatibleProvider {
401    pub name: String,
402    pub base_url: String,
403    /// Environment variable name for the API key, if known.
404    pub env_var: Option<&'static str>,
405    pub model_prefixes: Vec<String>,
406}
407
408impl Provider for OpenAiCompatibleProvider {
409    fn name(&self) -> &str {
410        &self.name
411    }
412
413    fn base_url(&self) -> &str {
414        &self.base_url
415    }
416
417    fn env_var(&self) -> Option<&str> {
418        self.env_var
419    }
420
421    fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
422        Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
423    }
424
425    fn matches_model(&self, model: &str) -> bool {
426        self.model_prefixes
427            .iter()
428            .any(|prefix| model.starts_with(prefix.as_str()))
429    }
430}
431
432/// A data-driven provider backed by a [`ProviderConfig`] entry from providers.json.
433///
434/// Used for simple providers that are fully described by their JSON config.
435/// Complex providers (AWS Bedrock, Vertex AI, etc.) use dedicated implementations.
436///
437/// # Construction
438///
439/// Construct only via [`ConfigDrivenProvider::new`], which is intentionally
440/// `pub(crate)` — callers outside this crate must go through [`detect_provider`].
441///
442/// # `base_url` contract
443///
444/// [`Provider::base_url`] returns an empty string when the provider config has
445/// no `base_url` entry.  This is safe because [`detect_provider`] guards the
446/// `base_url.is_some()` condition before constructing a `ConfigDrivenProvider`,
447/// so a correctly-routed request will never produce an empty URL.  A manually
448/// constructed instance (hypothetically) would produce a clearly-broken URL
449/// (`/chat/completions`) that fails immediately at the HTTP layer.
450pub(crate) struct ConfigDrivenProvider {
451    config: &'static ProviderConfig,
452}
453
454impl ConfigDrivenProvider {
455    #[must_use]
456    pub(crate) fn new(config: &'static ProviderConfig) -> Self {
457        Self { config }
458    }
459}
460
461impl Provider for ConfigDrivenProvider {
462    fn name(&self) -> &str {
463        &self.config.name
464    }
465
466    fn base_url(&self) -> &str {
467        // Return an empty string when unconfigured; `transform_request` or the
468        // HTTP layer will surface a useful error before any network call goes out.
469        self.config.base_url.as_deref().unwrap_or("")
470    }
471
472    fn env_var(&self) -> Option<&str> {
473        self.config.auth.as_ref().and_then(|a| a.env_var.as_deref())
474    }
475
476    fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
477        if let Some(mappings) = &self.config.param_mappings
478            && let Some(obj) = body.as_object_mut()
479        {
480            for (from, to) in mappings {
481                if let Some(val) = obj.remove(from.as_str()) {
482                    obj.insert(to.clone(), val);
483                }
484            }
485        }
486        Ok(())
487    }
488
489    fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
490        let auth_type = self
491            .config
492            .auth
493            .as_ref()
494            .map(|a| &a.auth_type)
495            .unwrap_or(&AuthType::Bearer);
496
497        match auth_type {
498            // No auth header required; return None so callers skip it entirely.
499            AuthType::None => None,
500            AuthType::ApiKey => Some((Cow::Borrowed("x-api-key"), Cow::Borrowed(api_key))),
501            // Bearer, Unknown, and anything else defaults to Bearer token.
502            AuthType::Bearer | AuthType::Unknown => {
503                Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
504            }
505        }
506    }
507
508    fn matches_model(&self, model: &str) -> bool {
509        if let Some(prefixes) = &self.config.model_prefixes {
510            prefixes.iter().any(|p| model.starts_with(p.as_str()))
511        } else {
512            false
513        }
514    }
515}
516
517// ── Provider detection ───────────────────────────────────────────────────────
518
519/// Detect which provider to use based on model name.
520///
521/// Strategy:
522/// 1. OpenAI hardcoded patterns (gpt-*, o1-*, text-embedding-*, …).
523/// 2. Anthropic: `claude-*` model names or `anthropic/` prefix.
524/// 3. Azure: `azure/` prefix.
525/// 4. Google AI Studio: `gemini/` or `google_ai/` prefix.
526/// 5. Vertex AI: `vertex_ai/` prefix.
527/// 6. AWS Bedrock: `bedrock/` prefix.
528/// 7. `"provider/"` prefix — look up the prefix in the registry.
529/// 8. Walk all registry entries and check their `model_prefixes`.
530///
531/// Returns `None` when no built-in provider matches.  The caller should fall
532/// back to a config-specified `base_url` or default to [`OpenAiProvider`].
533///
534/// Complex providers (those listed in `complex_providers` in providers.json)
535/// are excluded from config-driven routing because they require custom
536/// auth/request logic beyond simple bearer tokens.
537pub fn detect_provider(model: &str) -> Option<Box<dyn Provider>> {
538    // 0. Custom (runtime-registered) providers take highest priority.
539    if let Some(provider) = custom::detect_custom_provider(model) {
540        return Some(provider);
541    }
542
543    // 1. OpenAI hardcoded patterns.
544    let openai = OpenAiProvider;
545    if openai.matches_model(model) {
546        return Some(Box::new(openai));
547    }
548
549    // 2. Anthropic: "claude-*" model names or "anthropic/" prefix.
550    let anthropic = anthropic::AnthropicProvider;
551    if anthropic.matches_model(model) {
552        return Some(Box::new(anthropic));
553    }
554
555    // 3. Azure: "azure/" prefix.
556    if model.starts_with("azure/") {
557        return Some(Box::new(azure::AzureProvider::new()));
558    }
559
560    // 4. Google AI Studio: "gemini/" or "google_ai/" prefix.
561    if model.starts_with("gemini/") || model.starts_with("google_ai/") {
562        return Some(Box::new(google_ai::GoogleAiProvider));
563    }
564
565    // 5. Vertex AI: "vertex_ai/" prefix.
566    if model.starts_with("vertex_ai/") {
567        return Some(Box::new(vertex::VertexAiProvider::from_env()));
568    }
569
570    // 6. AWS Bedrock: "bedrock/" prefix.
571    if model.starts_with("bedrock/") {
572        return Some(Box::new(bedrock::BedrockProvider::from_env()));
573    }
574
575    // 7. Cohere: "command-*" model names or "cohere/" prefix.
576    if model.starts_with("command-") || model.starts_with("cohere/") {
577        return Some(Box::new(cohere::CohereProvider));
578    }
579
580    // 8. Mistral: "mistral-*", "codestral-*", "pixtral-*" model names or "mistral/" prefix.
581    if model.starts_with("mistral-")
582        || model.starts_with("codestral-")
583        || model.starts_with("pixtral-")
584        || model.starts_with("mistral/")
585    {
586        return Some(Box::new(mistral::MistralProvider));
587    }
588
589    // 9. GitHub Copilot: "github_copilot/" prefix.
590    if model.starts_with("github_copilot/") {
591        return Some(Box::new(github_copilot::GithubCopilotProvider::from_env()));
592    }
593
594    // Grab the registry; if it failed to parse we cannot route.
595    let reg = match REGISTRY.as_ref() {
596        Ok(r) => r,
597        Err(_) => return None,
598    };
599
600    // 6. Slash-prefix routing (e.g. "groq/llama3-70b").
601    if let Some((prefix, _)) = model.split_once('/')
602        && let Some(cfg) = reg.providers.iter().find(|p| p.name == prefix)
603        && cfg.base_url.is_some()
604        && !reg.complex_providers.contains(&cfg.name)
605    {
606        // cfg is &'static ProviderConfig because reg comes from LazyLock.
607        // Only use the registry entry if it has a usable base_url and is not
608        // a complex provider requiring dedicated auth logic.
609        return Some(Box::new(ConfigDrivenProvider::new(cfg)));
610    }
611
612    // 7. Walk registry model_prefixes for unprefixed model names.
613    for cfg in &reg.providers {
614        if reg.complex_providers.contains(&cfg.name) {
615            continue;
616        }
617        if let Some(prefixes) = &cfg.model_prefixes {
618            let matches = prefixes
619                .iter()
620                .any(|p| model.starts_with(p.as_str()) && !p.ends_with('/'));
621            if matches && cfg.base_url.is_some() {
622                // cfg is &'static ProviderConfig because reg comes from LazyLock.
623                return Some(Box::new(ConfigDrivenProvider::new(cfg)));
624            }
625        }
626    }
627
628    None
629}
630
631/// Return the environment variable name for the API key of a named provider.
632///
633/// Looks up `provider_name` in the embedded registry and returns the
634/// `auth.env_var` field, when present.  Returns `None` when the provider is
635/// not found or has no configured env var.
636///
637/// This is a registry-level helper; for a resolved [`Provider`] instance use
638/// [`Provider::env_var`] directly.
639pub fn provider_env_var(provider_name: &str) -> Option<&'static str> {
640    let reg = REGISTRY.as_ref().ok()?;
641    reg.providers
642        .iter()
643        .find(|p| p.name == provider_name)
644        .and_then(|p| p.auth.as_ref())
645        .and_then(|a| a.env_var.as_deref())
646}
647
648/// Return all provider configs from the registry.
649///
650/// Useful for tooling, documentation generation, or runtime enumeration.
651pub fn all_providers() -> Result<&'static [ProviderConfig]> {
652    Ok(&registry()?.providers)
653}
654
655/// Return the set of complex provider names.
656///
657/// Complex providers require custom auth/routing logic beyond simple bearer
658/// tokens (e.g. AWS Bedrock SigV4, Vertex AI OAuth2).
659///
660/// The returned reference points into the static registry — no allocation.
661pub fn complex_provider_names() -> Result<&'static HashSet<String>> {
662    Ok(&registry()?.complex_providers)
663}