liter_llm 1.0.0.pre.rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +239 -0
  3. data/ext/liter_llm_rb/extconf.rb +65 -0
  4. data/ext/liter_llm_rb/native/.cargo/config.toml +23 -0
  5. data/ext/liter_llm_rb/native/Cargo.lock +3713 -0
  6. data/ext/liter_llm_rb/native/Cargo.toml +32 -0
  7. data/ext/liter_llm_rb/native/build.rs +15 -0
  8. data/ext/liter_llm_rb/native/src/lib.rs +1079 -0
  9. data/lib/liter_llm.rb +8 -0
  10. data/sig/liter_llm.rbs +416 -0
  11. data/vendor/Cargo.toml +54 -0
  12. data/vendor/liter-llm/Cargo.toml +92 -0
  13. data/vendor/liter-llm/README.md +252 -0
  14. data/vendor/liter-llm/schemas/pricing.json +40 -0
  15. data/vendor/liter-llm/schemas/providers.json +1662 -0
  16. data/vendor/liter-llm/src/auth/azure_ad.rs +264 -0
  17. data/vendor/liter-llm/src/auth/bedrock_sts.rs +353 -0
  18. data/vendor/liter-llm/src/auth/mod.rs +68 -0
  19. data/vendor/liter-llm/src/auth/vertex_oauth.rs +353 -0
  20. data/vendor/liter-llm/src/client/config.rs +351 -0
  21. data/vendor/liter-llm/src/client/managed.rs +622 -0
  22. data/vendor/liter-llm/src/client/mod.rs +864 -0
  23. data/vendor/liter-llm/src/cost.rs +212 -0
  24. data/vendor/liter-llm/src/error.rs +190 -0
  25. data/vendor/liter-llm/src/http/eventstream.rs +860 -0
  26. data/vendor/liter-llm/src/http/mod.rs +12 -0
  27. data/vendor/liter-llm/src/http/request.rs +438 -0
  28. data/vendor/liter-llm/src/http/retry.rs +72 -0
  29. data/vendor/liter-llm/src/http/streaming.rs +289 -0
  30. data/vendor/liter-llm/src/lib.rs +37 -0
  31. data/vendor/liter-llm/src/provider/anthropic.rs +2250 -0
  32. data/vendor/liter-llm/src/provider/azure.rs +579 -0
  33. data/vendor/liter-llm/src/provider/bedrock.rs +1543 -0
  34. data/vendor/liter-llm/src/provider/cohere.rs +654 -0
  35. data/vendor/liter-llm/src/provider/custom.rs +404 -0
  36. data/vendor/liter-llm/src/provider/google_ai.rs +281 -0
  37. data/vendor/liter-llm/src/provider/mistral.rs +188 -0
  38. data/vendor/liter-llm/src/provider/mod.rs +616 -0
  39. data/vendor/liter-llm/src/provider/vertex.rs +1504 -0
  40. data/vendor/liter-llm/src/tests.rs +1425 -0
  41. data/vendor/liter-llm/src/tokenizer.rs +281 -0
  42. data/vendor/liter-llm/src/tower/budget.rs +599 -0
  43. data/vendor/liter-llm/src/tower/cache.rs +502 -0
  44. data/vendor/liter-llm/src/tower/cache_opendal.rs +270 -0
  45. data/vendor/liter-llm/src/tower/cooldown.rs +231 -0
  46. data/vendor/liter-llm/src/tower/cost.rs +404 -0
  47. data/vendor/liter-llm/src/tower/fallback.rs +121 -0
  48. data/vendor/liter-llm/src/tower/health.rs +219 -0
  49. data/vendor/liter-llm/src/tower/hooks.rs +369 -0
  50. data/vendor/liter-llm/src/tower/mod.rs +77 -0
  51. data/vendor/liter-llm/src/tower/rate_limit.rs +300 -0
  52. data/vendor/liter-llm/src/tower/router.rs +436 -0
  53. data/vendor/liter-llm/src/tower/service.rs +181 -0
  54. data/vendor/liter-llm/src/tower/tests.rs +539 -0
  55. data/vendor/liter-llm/src/tower/tests_common.rs +252 -0
  56. data/vendor/liter-llm/src/tower/tracing.rs +209 -0
  57. data/vendor/liter-llm/src/tower/types.rs +170 -0
  58. data/vendor/liter-llm/src/types/audio.rs +52 -0
  59. data/vendor/liter-llm/src/types/batch.rs +77 -0
  60. data/vendor/liter-llm/src/types/chat.rs +214 -0
  61. data/vendor/liter-llm/src/types/common.rs +244 -0
  62. data/vendor/liter-llm/src/types/embedding.rs +84 -0
  63. data/vendor/liter-llm/src/types/files.rs +58 -0
  64. data/vendor/liter-llm/src/types/image.rs +40 -0
  65. data/vendor/liter-llm/src/types/mod.rs +27 -0
  66. data/vendor/liter-llm/src/types/models.rs +21 -0
  67. data/vendor/liter-llm/src/types/moderation.rs +80 -0
  68. data/vendor/liter-llm/src/types/ocr.rs +87 -0
  69. data/vendor/liter-llm/src/types/rerank.rs +46 -0
  70. data/vendor/liter-llm/src/types/responses.rs +55 -0
  71. data/vendor/liter-llm/src/types/search.rs +45 -0
  72. data/vendor/liter-llm/tests/contract.rs +332 -0
  73. data/vendor/liter-llm-ffi/Cargo.toml +30 -0
  74. data/vendor/liter-llm-ffi/build.rs +66 -0
  75. data/vendor/liter-llm-ffi/cbindgen.toml +60 -0
  76. data/vendor/liter-llm-ffi/liter_llm.h +850 -0
  77. data/vendor/liter-llm-ffi/src/lib.rs +2488 -0
  78. metadata +286 -0
@@ -0,0 +1,616 @@
1
+ use std::borrow::Cow;
2
+ use std::collections::{HashMap, HashSet};
3
+ use std::sync::LazyLock;
4
+ use std::time::{SystemTime, UNIX_EPOCH};
5
+
6
+ use serde::Deserialize;
7
+
8
+ use 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).
15
+ pub(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)]
27
+ pub 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.
35
+ const 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).
40
+ static 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.
44
+ fn 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)]
53
+ struct 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
+
63
+ fn deserialize_hashset<'de, D>(deserializer: D) -> std::result::Result<HashSet<String>, D::Error>
64
+ where
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)]
73
+ pub 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")]
91
+ pub 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)]
106
+ pub 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.
115
+ pub 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
+ /// Provider name (e.g., "openai").
129
+ fn name(&self) -> &str;
130
+
131
+ /// Base URL (e.g., "https://api.openai.com/v1").
132
+ fn base_url(&self) -> &str;
133
+
134
+ /// Build the authorization header as `Some((header-name, header-value))`.
135
+ ///
136
+ /// Returns `None` when the provider requires no authentication header
137
+ /// (e.g. local models or providers with `auth: none`). Callers must skip
138
+ /// inserting any header when `None` is returned.
139
+ ///
140
+ /// When `Some`, returns a static header name and a borrowed-or-owned value
141
+ /// to avoid allocating the header name string on every request.
142
+ fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)>;
143
+
144
+ /// Additional static headers required by this provider beyond the auth header.
145
+ ///
146
+ /// Most providers return an empty slice. Use this for provider-mandated
147
+ /// headers like Anthropic's `anthropic-version`.
148
+ fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
149
+ &[]
150
+ }
151
+
152
+ /// Compute request-dependent headers based on the request body.
153
+ ///
154
+ /// Called by the client for each request. Use this for headers that
155
+ /// vary per-request, like Anthropic's `anthropic-beta` which depends
156
+ /// on whether thinking or hosted tools are enabled.
157
+ ///
158
+ /// The default implementation returns an empty vector.
159
+ fn dynamic_headers(&self, _body: &serde_json::Value) -> Vec<(String, String)> {
160
+ vec![]
161
+ }
162
+
163
+ /// Whether this provider matches a given model string.
164
+ fn matches_model(&self, model: &str) -> bool;
165
+
166
+ /// Strip any provider-routing prefix from a model name before sending it
167
+ /// in the request body.
168
+ ///
169
+ /// E.g. `"groq/llama3-70b"` → `"llama3-70b"`.
170
+ /// Returns the model name unchanged when no prefix is present.
171
+ fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
172
+ // Try "name/" prefix without allocating.
173
+ if let Some(rest) = model.strip_prefix(self.name())
174
+ && let Some(stripped) = rest.strip_prefix('/')
175
+ {
176
+ return stripped;
177
+ }
178
+ model
179
+ }
180
+
181
+ /// Path for chat completions endpoint.
182
+ fn chat_completions_path(&self) -> &str {
183
+ "/chat/completions"
184
+ }
185
+
186
+ /// Path for embeddings endpoint.
187
+ fn embeddings_path(&self) -> &str {
188
+ "/embeddings"
189
+ }
190
+
191
+ /// Path for list models endpoint.
192
+ fn models_path(&self) -> &str {
193
+ "/models"
194
+ }
195
+
196
+ /// Path for image generations endpoint.
197
+ fn image_generations_path(&self) -> &str {
198
+ "/images/generations"
199
+ }
200
+
201
+ /// Path for text-to-speech endpoint.
202
+ fn audio_speech_path(&self) -> &str {
203
+ "/audio/speech"
204
+ }
205
+
206
+ /// Path for audio transcription endpoint.
207
+ fn audio_transcriptions_path(&self) -> &str {
208
+ "/audio/transcriptions"
209
+ }
210
+
211
+ /// Path for content moderation endpoint.
212
+ fn moderations_path(&self) -> &str {
213
+ "/moderations"
214
+ }
215
+
216
+ /// Path for document reranking endpoint.
217
+ fn rerank_path(&self) -> &str {
218
+ "/rerank"
219
+ }
220
+
221
+ /// Path for the files management endpoint (e.g. POST /files, GET /files/{id}).
222
+ fn files_path(&self) -> &str {
223
+ "/files"
224
+ }
225
+
226
+ /// Path for the batches management endpoint (e.g. POST /batches, GET /batches/{id}).
227
+ fn batches_path(&self) -> &str {
228
+ "/batches"
229
+ }
230
+
231
+ /// Path for the responses endpoint (e.g. POST /responses).
232
+ fn responses_path(&self) -> &str {
233
+ "/responses"
234
+ }
235
+
236
+ /// Path for the web/document search endpoint.
237
+ fn search_path(&self) -> &str {
238
+ "/search"
239
+ }
240
+
241
+ /// Path for the OCR (optical character recognition) endpoint.
242
+ fn ocr_path(&self) -> &str {
243
+ "/ocr"
244
+ }
245
+
246
+ /// Whether streaming is supported.
247
+ #[allow(dead_code)] // reserved for future provider-capability checking
248
+ fn supports_streaming(&self) -> bool {
249
+ true
250
+ }
251
+
252
+ /// Transform the request body before sending, if needed.
253
+ fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
254
+ let _ = body;
255
+ Ok(())
256
+ }
257
+
258
+ /// Transform the raw response JSON before deserialization into canonical types.
259
+ ///
260
+ /// Providers returning non-OpenAI formats (Anthropic, Bedrock, Vertex) override
261
+ /// this to normalize their native response into OpenAI-compatible JSON.
262
+ /// The default implementation is a no-op (OpenAI-compatible responses pass through
263
+ /// unchanged).
264
+ fn transform_response(&self, _body: &mut serde_json::Value) -> Result<()> {
265
+ Ok(())
266
+ }
267
+
268
+ /// Build the full URL for a specific endpoint and model.
269
+ ///
270
+ /// Default: `{base_url}{endpoint_path}`. Providers like Azure and Bedrock
271
+ /// override this to embed deployment names, model IDs, or query parameters
272
+ /// into the URL.
273
+ fn build_url(&self, endpoint_path: &str, _model: &str) -> String {
274
+ format!("{}{}", self.base_url(), endpoint_path)
275
+ }
276
+
277
+ /// Parse a single SSE event data string into a `ChatCompletionChunk`.
278
+ ///
279
+ /// Default: OpenAI format (straight JSON parse).
280
+ /// Anthropic and Vertex override for their native streaming event formats.
281
+ ///
282
+ /// The `[DONE]` sentinel is handled at the SSE parser level before this
283
+ /// method is called, so implementations do not need to check for it.
284
+ ///
285
+ /// Returns `Ok(Some(chunk))` for a successfully parsed event.
286
+ /// Returns `Ok(None)` to skip this event (continue reading the stream).
287
+ /// Returns `Err` when the event cannot be parsed.
288
+ fn parse_stream_event(&self, event_data: &str) -> Result<Option<crate::types::ChatCompletionChunk>> {
289
+ serde_json::from_str::<crate::types::ChatCompletionChunk>(event_data)
290
+ .map(Some)
291
+ .map_err(|e| LiterLlmError::Streaming {
292
+ message: format!("failed to parse SSE data: {e}"),
293
+ })
294
+ }
295
+
296
+ /// The streaming wire format this provider uses.
297
+ ///
298
+ /// Default: [`StreamFormat::Sse`]. Override for providers that use
299
+ /// non-SSE framing (e.g. AWS Bedrock EventStream).
300
+ fn stream_format(&self) -> StreamFormat {
301
+ StreamFormat::Sse
302
+ }
303
+
304
+ /// Build the full URL for a streaming request.
305
+ ///
306
+ /// Default: delegates to [`Provider::build_url`]. Providers whose
307
+ /// streaming endpoint differs from the non-streaming one (e.g. Bedrock
308
+ /// uses `/converse-stream` vs `/converse`) override this.
309
+ fn build_stream_url(&self, endpoint_path: &str, model: &str) -> String {
310
+ self.build_url(endpoint_path, model)
311
+ }
312
+
313
+ /// Compute dynamic signing headers for the outgoing request.
314
+ ///
315
+ /// Called by the client just before sending each request. The default
316
+ /// implementation returns an empty vector (no extra signing required).
317
+ ///
318
+ /// Providers that use request-signing (e.g. AWS Bedrock with SigV4) override
319
+ /// this to return the computed `Authorization`, `x-amz-date`, and
320
+ /// `x-amz-security-token` headers. The returned headers are merged with the
321
+ /// provider's static [`Provider::extra_headers`] before the request is sent.
322
+ ///
323
+ /// # Arguments
324
+ ///
325
+ /// - `method`: HTTP method string, e.g. `"POST"`.
326
+ /// - `url`: Full request URL including path and query string.
327
+ /// - `body`: Serialised request body bytes (used in the payload hash).
328
+ fn signing_headers(&self, method: &str, url: &str, body: &[u8]) -> Vec<(String, String)> {
329
+ let _ = (method, url, body);
330
+ vec![]
331
+ }
332
+ }
333
+
334
+ pub mod anthropic;
335
+ pub mod azure;
336
+ pub mod bedrock;
337
+ pub mod cohere;
338
+ pub mod custom;
339
+ pub mod google_ai;
340
+ pub mod mistral;
341
+ pub mod vertex;
342
+
343
+ // ── Built-in providers ───────────────────────────────────────────────────────
344
+
345
+ /// Built-in OpenAI provider.
346
+ pub struct OpenAiProvider;
347
+
348
+ impl Provider for OpenAiProvider {
349
+ fn name(&self) -> &str {
350
+ "openai"
351
+ }
352
+
353
+ fn base_url(&self) -> &str {
354
+ "https://api.openai.com/v1"
355
+ }
356
+
357
+ fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
358
+ Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
359
+ }
360
+
361
+ fn matches_model(&self, model: &str) -> bool {
362
+ model.starts_with("gpt-")
363
+ || model.starts_with("o1-")
364
+ || model.starts_with("o3-")
365
+ || model.starts_with("o4-")
366
+ || model == "o1"
367
+ || model == "o3"
368
+ || model == "o4"
369
+ || model.starts_with("dall-e-")
370
+ || model.starts_with("whisper-")
371
+ || model.starts_with("tts-")
372
+ || model.starts_with("text-embedding-")
373
+ || model.starts_with("chatgpt-")
374
+ || model.starts_with("openai/")
375
+ }
376
+
377
+ fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
378
+ model.strip_prefix("openai/").unwrap_or(model)
379
+ }
380
+ }
381
+
382
+ /// A generic OpenAI-compatible provider (configurable base_url + bearer auth).
383
+ pub struct OpenAiCompatibleProvider {
384
+ pub name: String,
385
+ pub base_url: String,
386
+ #[allow(dead_code)] // reserved for future env-var based key injection
387
+ pub env_var: Option<&'static str>,
388
+ pub model_prefixes: Vec<String>,
389
+ }
390
+
391
+ impl Provider for OpenAiCompatibleProvider {
392
+ fn name(&self) -> &str {
393
+ &self.name
394
+ }
395
+
396
+ fn base_url(&self) -> &str {
397
+ &self.base_url
398
+ }
399
+
400
+ fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
401
+ Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
402
+ }
403
+
404
+ fn matches_model(&self, model: &str) -> bool {
405
+ self.model_prefixes
406
+ .iter()
407
+ .any(|prefix| model.starts_with(prefix.as_str()))
408
+ }
409
+ }
410
+
411
+ /// A data-driven provider backed by a [`ProviderConfig`] entry from providers.json.
412
+ ///
413
+ /// Used for simple providers that are fully described by their JSON config.
414
+ /// Complex providers (AWS Bedrock, Vertex AI, etc.) use dedicated implementations.
415
+ ///
416
+ /// # Construction
417
+ ///
418
+ /// Construct only via [`ConfigDrivenProvider::new`], which is intentionally
419
+ /// `pub(crate)` — callers outside this crate must go through [`detect_provider`].
420
+ ///
421
+ /// # `base_url` contract
422
+ ///
423
+ /// [`Provider::base_url`] returns an empty string when the provider config has
424
+ /// no `base_url` entry. This is safe because [`detect_provider`] guards the
425
+ /// `base_url.is_some()` condition before constructing a `ConfigDrivenProvider`,
426
+ /// so a correctly-routed request will never produce an empty URL. A manually
427
+ /// constructed instance (hypothetically) would produce a clearly-broken URL
428
+ /// (`/chat/completions`) that fails immediately at the HTTP layer.
429
+ pub struct ConfigDrivenProvider {
430
+ config: &'static ProviderConfig,
431
+ }
432
+
433
+ impl ConfigDrivenProvider {
434
+ #[must_use]
435
+ pub(crate) fn new(config: &'static ProviderConfig) -> Self {
436
+ Self { config }
437
+ }
438
+ }
439
+
440
+ impl Provider for ConfigDrivenProvider {
441
+ fn name(&self) -> &str {
442
+ &self.config.name
443
+ }
444
+
445
+ fn base_url(&self) -> &str {
446
+ // Return an empty string when unconfigured; `transform_request` or the
447
+ // HTTP layer will surface a useful error before any network call goes out.
448
+ self.config.base_url.as_deref().unwrap_or("")
449
+ }
450
+
451
+ fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
452
+ if let Some(mappings) = &self.config.param_mappings
453
+ && let Some(obj) = body.as_object_mut()
454
+ {
455
+ for (from, to) in mappings {
456
+ if let Some(val) = obj.remove(from.as_str()) {
457
+ obj.insert(to.clone(), val);
458
+ }
459
+ }
460
+ }
461
+ Ok(())
462
+ }
463
+
464
+ fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
465
+ let auth_type = self
466
+ .config
467
+ .auth
468
+ .as_ref()
469
+ .map(|a| &a.auth_type)
470
+ .unwrap_or(&AuthType::Bearer);
471
+
472
+ match auth_type {
473
+ // No auth header required; return None so callers skip it entirely.
474
+ AuthType::None => None,
475
+ AuthType::ApiKey => Some((Cow::Borrowed("x-api-key"), Cow::Borrowed(api_key))),
476
+ // Bearer, Unknown, and anything else defaults to Bearer token.
477
+ AuthType::Bearer | AuthType::Unknown => {
478
+ Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
479
+ }
480
+ }
481
+ }
482
+
483
+ fn matches_model(&self, model: &str) -> bool {
484
+ if let Some(prefixes) = &self.config.model_prefixes {
485
+ prefixes.iter().any(|p| model.starts_with(p.as_str()))
486
+ } else {
487
+ false
488
+ }
489
+ }
490
+ }
491
+
492
+ // ── Provider detection ───────────────────────────────────────────────────────
493
+
494
+ /// Detect which provider to use based on model name.
495
+ ///
496
+ /// Strategy:
497
+ /// 1. OpenAI hardcoded patterns (gpt-*, o1-*, text-embedding-*, …).
498
+ /// 2. Anthropic: `claude-*` model names or `anthropic/` prefix.
499
+ /// 3. Azure: `azure/` prefix.
500
+ /// 4. Google AI Studio: `gemini/` or `google_ai/` prefix.
501
+ /// 5. Vertex AI: `vertex_ai/` prefix.
502
+ /// 6. AWS Bedrock: `bedrock/` prefix.
503
+ /// 7. `"provider/"` prefix — look up the prefix in the registry.
504
+ /// 8. Walk all registry entries and check their `model_prefixes`.
505
+ ///
506
+ /// Returns `None` when no built-in provider matches. The caller should fall
507
+ /// back to a config-specified `base_url` or default to [`OpenAiProvider`].
508
+ ///
509
+ /// Complex providers (those listed in `complex_providers` in providers.json)
510
+ /// are excluded from config-driven routing because they require custom
511
+ /// auth/request logic beyond simple bearer tokens.
512
+ pub fn detect_provider(model: &str) -> Option<Box<dyn Provider>> {
513
+ // 0. Custom (runtime-registered) providers take highest priority.
514
+ if let Some(provider) = custom::detect_custom_provider(model) {
515
+ return Some(provider);
516
+ }
517
+
518
+ // 1. OpenAI hardcoded patterns.
519
+ let openai = OpenAiProvider;
520
+ if openai.matches_model(model) {
521
+ return Some(Box::new(openai));
522
+ }
523
+
524
+ // 2. Anthropic: "claude-*" model names or "anthropic/" prefix.
525
+ let anthropic = anthropic::AnthropicProvider;
526
+ if anthropic.matches_model(model) {
527
+ return Some(Box::new(anthropic));
528
+ }
529
+
530
+ // 3. Azure: "azure/" prefix.
531
+ if model.starts_with("azure/") {
532
+ return Some(Box::new(azure::AzureProvider::new()));
533
+ }
534
+
535
+ // 4. Google AI Studio: "gemini/" or "google_ai/" prefix.
536
+ if model.starts_with("gemini/") || model.starts_with("google_ai/") {
537
+ return Some(Box::new(google_ai::GoogleAiProvider));
538
+ }
539
+
540
+ // 5. Vertex AI: "vertex_ai/" prefix.
541
+ if model.starts_with("vertex_ai/") {
542
+ return Some(Box::new(vertex::VertexAiProvider::from_env()));
543
+ }
544
+
545
+ // 6. AWS Bedrock: "bedrock/" prefix.
546
+ if model.starts_with("bedrock/") {
547
+ return Some(Box::new(bedrock::BedrockProvider::from_env()));
548
+ }
549
+
550
+ // 7. Cohere: "command-*" model names or "cohere/" prefix.
551
+ if model.starts_with("command-") || model.starts_with("cohere/") {
552
+ return Some(Box::new(cohere::CohereProvider));
553
+ }
554
+
555
+ // 8. Mistral: "mistral-*", "codestral-*", "pixtral-*" model names or "mistral/" prefix.
556
+ if model.starts_with("mistral-")
557
+ || model.starts_with("codestral-")
558
+ || model.starts_with("pixtral-")
559
+ || model.starts_with("mistral/")
560
+ {
561
+ return Some(Box::new(mistral::MistralProvider));
562
+ }
563
+
564
+ // Grab the registry; if it failed to parse we cannot route.
565
+ let reg = match REGISTRY.as_ref() {
566
+ Ok(r) => r,
567
+ Err(_) => return None,
568
+ };
569
+
570
+ // 6. Slash-prefix routing (e.g. "groq/llama3-70b").
571
+ if let Some((prefix, _)) = model.split_once('/')
572
+ && let Some(cfg) = reg.providers.iter().find(|p| p.name == prefix)
573
+ && cfg.base_url.is_some()
574
+ && !reg.complex_providers.contains(&cfg.name)
575
+ {
576
+ // cfg is &'static ProviderConfig because reg comes from LazyLock.
577
+ // Only use the registry entry if it has a usable base_url and is not
578
+ // a complex provider requiring dedicated auth logic.
579
+ return Some(Box::new(ConfigDrivenProvider::new(cfg)));
580
+ }
581
+
582
+ // 7. Walk registry model_prefixes for unprefixed model names.
583
+ for cfg in &reg.providers {
584
+ if reg.complex_providers.contains(&cfg.name) {
585
+ continue;
586
+ }
587
+ if let Some(prefixes) = &cfg.model_prefixes {
588
+ let matches = prefixes
589
+ .iter()
590
+ .any(|p| model.starts_with(p.as_str()) && !p.ends_with('/'));
591
+ if matches && cfg.base_url.is_some() {
592
+ // cfg is &'static ProviderConfig because reg comes from LazyLock.
593
+ return Some(Box::new(ConfigDrivenProvider::new(cfg)));
594
+ }
595
+ }
596
+ }
597
+
598
+ None
599
+ }
600
+
601
+ /// Return all provider configs from the registry.
602
+ ///
603
+ /// Useful for tooling, documentation generation, or runtime enumeration.
604
+ pub fn all_providers() -> Result<&'static [ProviderConfig]> {
605
+ Ok(&registry()?.providers)
606
+ }
607
+
608
+ /// Return the set of complex provider names.
609
+ ///
610
+ /// Complex providers require custom auth/routing logic beyond simple bearer
611
+ /// tokens (e.g. AWS Bedrock SigV4, Vertex AI OAuth2).
612
+ ///
613
+ /// The returned reference points into the static registry — no allocation.
614
+ pub fn complex_provider_names() -> Result<&'static HashSet<String>> {
615
+ Ok(&registry()?.complex_providers)
616
+ }