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 ®.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(®istry()?.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(®istry()?.complex_providers)
663}