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, Serialize};
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/// Validate that a sampling parameter present in the request body falls within a
23/// provider's documented range, rejecting it with a [`LiterLlmError::BadRequest`]
24/// when it does not.
25///
26/// `ChatCompletionRequest::temperature` and `top_p` are documented at the widest
27/// range accepted by any supported provider, so a value that is legal per that
28/// doc can still be illegal for a specific provider (e.g. Anthropic caps
29/// `temperature` at `1.0`, not OpenAI's `2.0`). Rejecting the value here, before
30/// the request reaches the wire, turns an opaque provider-side 400 into a local,
31/// actionable error instead of silently rewriting the caller's requested
32/// sampling behaviour, which a clamp would do. A missing field is not an error;
33/// only a present, out-of-range value is rejected.
34pub(crate) fn validate_sampling_param_range(
35    body: &serde_json::Value,
36    field: &str,
37    provider: &str,
38    min: f64,
39    max: f64,
40) -> Result<()> {
41    let Some(value) = body.get(field).and_then(serde_json::Value::as_f64) else {
42        return Ok(());
43    };
44    if value < min || value > max {
45        return Err(LiterLlmError::BadRequest {
46            message: format!(
47                "{field}={value} is outside {provider}'s supported range [{min}, {max}]; lower \
48                 the requested value or omit `{field}` to use the provider default"
49            ),
50            status: 400,
51        });
52    }
53    Ok(())
54}
55
56/// The streaming wire format a provider uses for its response stream.
57///
58/// Most providers use standard Server-Sent Events (SSE).  AWS Bedrock uses
59/// a proprietary binary EventStream framing.
60///
61/// Deserialized from the `streaming_format` JSON field via [`serde`].
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
63#[serde(rename_all = "snake_case")]
64pub enum StreamFormat {
65    /// Standard Server-Sent Events (text/event-stream).
66    #[default]
67    Sse,
68    /// AWS EventStream binary framing (application/vnd.amazon.eventstream).
69    AwsEventStream,
70}
71
72/// Static capability flags for a provider.
73///
74/// Each flag indicates whether the provider's models *generally* support that
75/// feature.  For providers that aggregate many underlying models (e.g. Bedrock,
76/// OpenRouter, vLLM) the flags reflect the superset of available model
77/// capabilities — a flag being `true` means at least one model supports the
78/// feature, not every model.
79///
80/// All flags default to `false` so that newly added providers are safe.
81///
82/// Access via the crate-level [`capabilities`] function:
83///
84/// ```rust
85/// use liter_llm::capabilities;
86///
87/// let caps = capabilities("openai");
88/// assert!(caps.function_calling);
89/// assert!(caps.vision);
90///
91/// // Unknown providers return a default-all-false reference.
92/// let unknown = capabilities("my-private-model");
93/// assert!(!unknown.function_calling);
94/// ```
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
96pub struct ProviderCapabilities {
97    /// The provider accepts image input in chat messages.
98    pub vision: bool,
99    /// The provider supports extended-thinking / reasoning tokens.
100    pub reasoning: bool,
101    /// The provider supports JSON-mode or `response_format` structured output.
102    pub structured_output: bool,
103    /// The provider supports tool / function calling.
104    pub function_calling: bool,
105    /// The provider accepts audio as input.
106    pub audio_in: bool,
107    /// The provider can generate audio / TTS output.
108    pub audio_out: bool,
109    /// The provider accepts video as input.
110    pub video_in: bool,
111}
112
113/// Static all-false sentinel returned by [`capabilities`] for unknown providers.
114static DEFAULT_CAPABILITIES: ProviderCapabilities = ProviderCapabilities {
115    vision: false,
116    reasoning: false,
117    structured_output: false,
118    function_calling: false,
119    audio_in: false,
120    audio_out: false,
121    video_in: false,
122};
123
124/// Return the capability flags for a named provider.
125///
126/// Performs an O(n) linear scan over the embedded registry (174 entries).
127/// Returns an owned value so bindings can pass capability data without
128/// borrowing registry internals.
129///
130/// For unknown `provider_name` values the function returns an all-`false`
131/// sentinel so callers never need to handle `Option`.
132pub fn capabilities(provider_name: &str) -> ProviderCapabilities {
133    let Ok(reg) = REGISTRY.as_ref() else {
134        return DEFAULT_CAPABILITIES;
135    };
136    for entry in &reg.providers {
137        if entry.config.name == provider_name {
138            return entry.capabilities;
139        }
140    }
141    DEFAULT_CAPABILITIES
142}
143
144const PROVIDERS_JSON: &str = include_str!("../../schemas/providers.json");
145
146/// Lazy-initialised registry parsed from the embedded JSON.
147/// Stores a `Result` so that parse failures surface at call time rather than
148/// panicking the process (fix for the `.expect()` on LazyLock).
149static REGISTRY: LazyLock<std::result::Result<ProviderRegistry, String>> = LazyLock::new(|| {
150    serde_json::from_str::<ProviderRegistryRaw>(PROVIDERS_JSON)
151        .map(ProviderRegistry::from_raw)
152        .map_err(|e| {
153            // ~keep Logged once here (LazyLock evaluates its closure exactly once) rather
154            // than at every `detect_provider`/`capabilities` call site that silently
155            // falls back to "no provider found" on this error.
156            tracing::error!(error = %e, "embedded schemas/providers.json failed to parse");
157            e.to_string()
158        })
159});
160
161/// Access the registry, returning an error if the embedded JSON was invalid.
162fn registry() -> Result<&'static ProviderRegistry> {
163    REGISTRY.as_ref().map_err(|e| LiterLlmError::ServerError {
164        message: format!("embedded schemas/providers.json is invalid: {e}"),
165        status: 500,
166    })
167}
168
169#[allow(dead_code)]
170#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
171struct RegionalEndpointConfig {
172    region: String,
173    openai_base_url: String,
174    anthropic_base_url: String,
175    docs_root: String,
176}
177
178/// Internal JSON shape: each provider entry with capability flags and streaming
179/// format, stored separately from the public [`ProviderConfig`] schema.
180#[derive(Debug, Deserialize)]
181struct ProviderEntry {
182    #[serde(flatten)]
183    config: ProviderConfig,
184    /// Alternative `alias/` namespaces that route to this provider and are stripped like
185    /// `name/` (e.g. `lm_studio` for `lmstudio`). Kept off the public [`ProviderConfig`].
186    #[serde(default)]
187    aliases: Vec<String>,
188    /// Absolute URLs for endpoints that live outside `base_url` (e.g. DashScope rerank under
189    /// `/compatible-api/v1/reranks` while chat is under `/compatible-mode/v1`). Keyed by
190    /// endpoint kind; currently only `rerank` is consulted.
191    #[serde(default)]
192    endpoint_urls: HashMap<String, String>,
193    #[serde(default)]
194    capabilities: ProviderCapabilities,
195    /// Protocol-specific base URLs available in each supported region.
196    #[allow(dead_code)]
197    #[serde(default)]
198    regional_endpoints: Vec<RegionalEndpointConfig>,
199    /// Streaming wire format for this provider.
200    ///
201    /// Deserialized from `providers.json` and available to future workstreams
202    /// via `ProviderEntry` (e.g. to drive per-provider streaming-format routing
203    /// without hardcoding the Bedrock special-case in `detect_provider`).
204    #[allow(dead_code)]
205    #[serde(default, rename = "streaming_format")]
206    stream_format: StreamFormat,
207}
208
209/// Intermediate deserialization target for `providers.json`.
210#[derive(Debug, Deserialize)]
211struct ProviderRegistryRaw {
212    providers: Vec<ProviderEntry>,
213    /// Set of complex provider names for O(1) lookup.
214    ///
215    /// Deserialized from a JSON array; converted to a `HashSet` for fast
216    /// membership tests in the hot `detect_provider` path.
217    #[serde(default, deserialize_with = "deserialize_hashset")]
218    complex_providers: HashSet<String>,
219}
220
221/// The parsed and indexed registry.
222///
223/// `providers` holds the full entries including capability flags and streaming
224/// format (for internal routing and capability lookup).  `configs` is a
225/// pre-extracted public-config slice returned by [`all_providers`].
226#[derive(Debug)]
227struct ProviderRegistry {
228    providers: Vec<ProviderEntry>,
229    configs: Vec<ProviderConfig>,
230    complex_providers: HashSet<String>,
231}
232
233impl ProviderRegistry {
234    fn from_raw(raw: ProviderRegistryRaw) -> Self {
235        let configs = raw.providers.iter().map(|e| e.config.clone()).collect();
236        Self {
237            configs,
238            providers: raw.providers,
239            complex_providers: raw.complex_providers,
240        }
241    }
242}
243
244fn deserialize_hashset<'de, D>(deserializer: D) -> std::result::Result<HashSet<String>, D::Error>
245where
246    D: serde::Deserializer<'de>,
247{
248    let vec = Vec::<String>::deserialize(deserializer)?;
249    Ok(vec.into_iter().collect())
250}
251
252/// Static configuration for a single provider entry in providers.json.
253///
254/// This struct deliberately does not include capability flags or streaming
255/// format, which are accessed via the [`capabilities`] function.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct ProviderConfig {
258    /// Provider identifier (matches the entry key in providers.json).
259    pub name: String,
260    /// Human-readable provider name shown in UIs.
261    pub display_name: Option<String>,
262    /// Base URL used as the default for this provider's HTTP client.
263    pub base_url: Option<String>,
264    /// Authentication scheme metadata (auth type + env var holding the key).
265    pub auth: Option<AuthConfig>,
266    /// Supported endpoint kinds (e.g. `chat`, `embeddings`).
267    pub endpoints: Option<Vec<String>>,
268    /// Model-name prefixes claimed by this provider (e.g. `["gpt-", "o1-"]`).
269    pub model_prefixes: Option<Vec<String>>,
270    /// Parameter key renaming for this provider.
271    ///
272    /// Each entry maps an OpenAI-spec field name (e.g. `"max_completion_tokens"`)
273    /// to the name this provider expects (e.g. `"max_tokens"`).  Applied
274    /// automatically by `ConfigDrivenProvider::transform_request`.
275    pub param_mappings: Option<HashMap<String, String>>,
276}
277
278/// Auth scheme used by a provider.
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
280#[serde(rename_all = "kebab-case")]
281pub enum AuthType {
282    /// Standard `Authorization: Bearer <key>` header.
283    Bearer,
284    /// `x-api-key: <key>` header (also handles `"header"` and `"x-api-key"` aliases).
285    #[serde(alias = "header", alias = "x-api-key")]
286    ApiKey,
287    /// No authentication header required.
288    None,
289    /// Unrecognised auth scheme — falls back to bearer.
290    #[serde(other)]
291    Unknown,
292}
293
294/// Auth configuration block.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct AuthConfig {
297    /// Auth scheme classification.
298    #[serde(rename = "type")]
299    pub auth_type: AuthType,
300    /// Name of the environment variable that holds the API key (e.g. `"OPENAI_API_KEY"`).
301    /// Holds the variable name, never the secret value.
302    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
303    pub env_var: Option<String>,
304}
305
306/// A provider defines how to reach an LLM API endpoint.
307pub(crate) trait Provider: Send + Sync {
308    /// Validate provider configuration at construction time.
309    ///
310    /// Called by [`DefaultClient::new`] immediately after the provider is
311    /// resolved.  Returning an error here surfaces misconfiguration early
312    /// (e.g. missing Azure `base_url`) rather than on the first request.
313    ///
314    /// The default implementation is a no-op; providers with required
315    /// configuration fields (like Azure) override this.
316    fn validate(&self) -> Result<()> {
317        Ok(())
318    }
319
320    /// Name of the environment variable that holds the API key for this provider.
321    ///
322    /// Returns `None` for providers that do not use an API key (e.g. auth type
323    /// `none`), or for providers whose key source is handled out-of-band (e.g.
324    /// AWS Bedrock credentials resolved via the AWS SDK).
325    ///
326    /// Used by [`DefaultClient::new`] to auto-load the API key from the
327    /// environment when `load_env` is enabled and no explicit key was provided.
328    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
329    fn env_var(&self) -> Option<&str> {
330        None
331    }
332
333    /// Provider name (e.g., "openai").
334    fn name(&self) -> &str;
335
336    /// Base URL (e.g., "https://api.openai.com/v1").
337    fn base_url(&self) -> &str;
338
339    /// Build the authorization header as `Some((header-name, header-value))`.
340    ///
341    /// Returns `None` when the provider requires no authentication header
342    /// (e.g. local models or providers with `auth: none`).  Callers must skip
343    /// inserting any header when `None` is returned.
344    ///
345    /// When `Some`, returns a static header name and a borrowed-or-owned value
346    /// to avoid allocating the header name string on every request.
347    fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)>;
348
349    /// Additional static headers required by this provider beyond the auth header.
350    ///
351    /// Most providers return an empty slice.  Use this for provider-mandated
352    /// headers like Anthropic's `anthropic-version`.
353    fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
354        &[]
355    }
356
357    /// Compute request-dependent headers based on the request body.
358    ///
359    /// Called by the client for each request. Use this for headers that
360    /// vary per-request, like Anthropic's `anthropic-beta` which depends
361    /// on whether thinking or hosted tools are enabled.
362    ///
363    /// The default implementation returns an empty vector.
364    fn dynamic_headers(&self, _body: &serde_json::Value) -> Vec<(String, String)> {
365        vec![]
366    }
367
368    /// Whether this provider matches a given model string.
369    fn matches_model(&self, model: &str) -> bool;
370
371    /// Strip any provider-routing prefix from a model name before sending it
372    /// in the request body.
373    ///
374    /// E.g. `"groq/llama3-70b"` → `"llama3-70b"`.
375    /// Returns the model name unchanged when no prefix is present.
376    fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
377        if let Some(rest) = model.strip_prefix(self.name())
378            && let Some(stripped) = rest.strip_prefix('/')
379        {
380            return stripped;
381        }
382        model
383    }
384
385    /// Path for chat completions endpoint.
386    fn chat_completions_path(&self) -> &str {
387        "/chat/completions"
388    }
389
390    /// Path for embeddings endpoint.
391    fn embeddings_path(&self) -> &str {
392        "/embeddings"
393    }
394
395    /// Path for list models endpoint.
396    fn models_path(&self) -> &str {
397        "/models"
398    }
399
400    /// Path for image generations endpoint.
401    fn image_generations_path(&self) -> &str {
402        "/images/generations"
403    }
404
405    /// Path for text-to-speech endpoint.
406    fn audio_speech_path(&self) -> &str {
407        "/audio/speech"
408    }
409
410    /// Path for audio transcription endpoint.
411    fn audio_transcriptions_path(&self) -> &str {
412        "/audio/transcriptions"
413    }
414
415    /// Path for content moderation endpoint.
416    fn moderations_path(&self) -> &str {
417        "/moderations"
418    }
419
420    /// Path for document reranking endpoint.
421    fn rerank_path(&self) -> &str {
422        "/rerank"
423    }
424
425    /// Path for the files management endpoint (e.g. POST /files, GET /files/{id}).
426    fn files_path(&self) -> &str {
427        "/files"
428    }
429
430    /// Path for the batches management endpoint (e.g. POST /batches, GET /batches/{id}).
431    fn batches_path(&self) -> &str {
432        "/batches"
433    }
434
435    /// Path for the responses endpoint (e.g. POST /responses).
436    fn responses_path(&self) -> &str {
437        "/responses"
438    }
439
440    /// Path for the web/document search endpoint.
441    fn search_path(&self) -> &str {
442        "/search"
443    }
444
445    /// Path for the OCR (optical character recognition) endpoint.
446    fn ocr_path(&self) -> &str {
447        "/ocr"
448    }
449
450    /// Whether streaming is supported.
451    #[allow(dead_code)]
452    fn supports_streaming(&self) -> bool {
453        true
454    }
455
456    /// Transform the request body before sending, if needed.
457    fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
458        let _ = body;
459        Ok(())
460    }
461
462    /// Transform the raw response JSON before deserialization into canonical types.
463    ///
464    /// Providers returning non-OpenAI formats (Anthropic, Bedrock, Vertex) override
465    /// this to normalize their native response into OpenAI-compatible JSON.
466    /// The default implementation is a no-op (OpenAI-compatible responses pass through
467    /// unchanged).
468    fn transform_response(&self, _body: &mut serde_json::Value) -> Result<()> {
469        Ok(())
470    }
471
472    /// Build the full URL for a specific endpoint and model.
473    ///
474    /// Default: `{base_url}{endpoint_path}`.  Providers like Azure and Bedrock
475    /// override this to embed deployment names, model IDs, or query parameters
476    /// into the URL.
477    fn build_url(&self, endpoint_path: &str, _model: &str) -> String {
478        let Ok(mut base) = url::Url::parse(self.base_url()) else {
479            return format!("{}{}", self.base_url(), endpoint_path);
480        };
481        let path = format!(
482            "{}/{}",
483            base.path().trim_end_matches('/'),
484            endpoint_path.trim_start_matches('/')
485        );
486        base.set_path(&path);
487        base.into()
488    }
489
490    /// Parse a single SSE event data string into a `ChatCompletionChunk`.
491    ///
492    /// Default: OpenAI format (straight JSON parse).
493    /// Anthropic and Vertex override for their native streaming event formats.
494    ///
495    /// The `[DONE]` sentinel is handled at the SSE parser level before this
496    /// method is called, so implementations do not need to check for it.
497    ///
498    /// Returns `Ok(Some(chunk))` for a successfully parsed event.
499    /// Returns `Ok(None)` to skip this event (continue reading the stream).
500    /// Returns `Err` when the event cannot be parsed.
501    fn parse_stream_event(&self, event_data: &str) -> Result<Option<crate::types::ChatCompletionChunk>> {
502        // ~keep An OpenAI-compatible provider may accept a stream (HTTP 200,
503        // `text/event-stream`) and then abort mid-stream by sending a `data:`
504        // line carrying an error object — `{"error": {"message": ..., "code":
505        // ..., "status_code": ...}}` — before closing the connection. Groq does
506        // this when a model emits a malformed tool call. The status lives in the
507        // payload, not the response code, so it must be recovered here.
508        //
509        // This check must run BEFORE deserialization: `ChatCompletionChunk`
510        // defaults every field (so providers may omit `id`/`choices`/etc. on
511        // legitimate events — see #155), which means an error object would
512        // otherwise decode into a valid-looking, content-free chunk and the
513        // failure would vanish, leaving callers awaiting content that is never
514        // sent.
515        if let Ok(value) = serde_json::from_str::<serde_json::Value>(event_data)
516            && let Some(error) = value.get("error")
517        {
518            let message = error
519                .get("message")
520                .and_then(serde_json::Value::as_str)
521                .unwrap_or("provider returned an error mid-stream")
522                .to_string();
523            let status = error
524                .get("status_code")
525                .and_then(serde_json::Value::as_u64)
526                .and_then(|s| u16::try_from(s).ok())
527                .unwrap_or(400);
528            return Err(LiterLlmError::BadRequest { message, status });
529        }
530
531        serde_json::from_str::<crate::types::ChatCompletionChunk>(event_data)
532            .map(Some)
533            .map_err(|e| LiterLlmError::Streaming {
534                message: format!("failed to parse SSE data: {e}"),
535            })
536    }
537
538    /// The streaming wire format this provider uses.
539    ///
540    /// Default: [`StreamFormat::Sse`].  Override for providers that use
541    /// non-SSE framing (e.g. AWS Bedrock EventStream).
542    fn stream_format(&self) -> StreamFormat {
543        StreamFormat::Sse
544    }
545
546    /// Build the full URL for a streaming request.
547    ///
548    /// Default: delegates to [`Provider::build_url`].  Providers whose
549    /// streaming endpoint differs from the non-streaming one (e.g. Bedrock
550    /// uses `/converse-stream` vs `/converse`) override this.
551    fn build_stream_url(&self, endpoint_path: &str, model: &str) -> String {
552        self.build_url(endpoint_path, model)
553    }
554
555    /// Compute dynamic signing headers for the outgoing request.
556    ///
557    /// Called by the client just before sending each request.  The default
558    /// implementation returns an empty vector (no extra signing required).
559    ///
560    /// Providers that use request-signing (e.g. AWS Bedrock with SigV4) override
561    /// this to return the computed `Authorization`, `x-amz-date`, and
562    /// `x-amz-security-token` headers.  The returned headers are merged with the
563    /// provider's static [`Provider::extra_headers`] before the request is sent.
564    ///
565    /// # Arguments
566    ///
567    /// - `method`: HTTP method string, e.g. `"POST"`.
568    /// - `url`: Full request URL including path and query string.
569    /// - `body`: Serialised request body bytes (used in the payload hash).
570    ///
571    /// # Errors
572    ///
573    /// Returns an error if the signing headers cannot be computed (e.g. an
574    /// internal signing-library failure). The default implementation never fails.
575    fn signing_headers(&self, method: &str, url: &str, body: &[u8]) -> Result<Vec<(String, String)>> {
576        let _ = (method, url, body);
577        Ok(vec![])
578    }
579
580    /// Sign with the credential resolved for this request, when configured.
581    ///
582    /// The default preserves static signing for providers without dynamic signing support.
583    ///
584    /// # Errors
585    ///
586    /// Returns an error when credentials are unsupported or request signing fails.
587    fn signing_headers_with_credential(
588        &self,
589        method: &str,
590        url: &str,
591        body: &[u8],
592        credential: Option<&crate::auth::Credential>,
593    ) -> Result<Vec<(String, String)>> {
594        let _ = credential;
595        self.signing_headers(method, url, body)
596    }
597}
598
599pub(crate) mod anthropic;
600pub(crate) mod azure;
601pub(crate) mod bedrock;
602pub(crate) mod cohere;
603pub mod custom;
604pub(crate) mod github_copilot;
605pub(crate) mod google_ai;
606pub(crate) mod mistral;
607pub mod outbound_policy;
608pub(crate) mod vertex;
609
610#[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
611pub use outbound_policy::configure_outbound_client_builder;
612#[cfg(any(feature = "native-http", feature = "wasm-http"))]
613pub(crate) use outbound_policy::outbound_forbidden_from_reqwest;
614pub use outbound_policy::{
615    OutboundPolicy, current_policy, set_outbound_policy, validate_outbound_url, validate_outbound_url_sync,
616};
617#[cfg(all(feature = "native-http", not(target_arch = "wasm32")))]
618pub(crate) use outbound_policy::{authenticated_outbound_client, configure_credential_free_outbound_client_builder};
619
620/// Built-in OpenAI provider.
621pub(crate) struct OpenAiProvider;
622
623impl Provider for OpenAiProvider {
624    fn name(&self) -> &str {
625        "openai"
626    }
627
628    fn base_url(&self) -> &str {
629        "https://api.openai.com/v1"
630    }
631
632    fn env_var(&self) -> Option<&str> {
633        Some("OPENAI_API_KEY")
634    }
635
636    fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
637        Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
638    }
639
640    fn matches_model(&self, model: &str) -> bool {
641        model.starts_with("gpt-")
642            || model.starts_with("o1-")
643            || model.starts_with("o3-")
644            || model.starts_with("o4-")
645            || model == "o1"
646            || model == "o3"
647            || model == "o4"
648            || model.starts_with("dall-e-")
649            || model.starts_with("whisper-")
650            || model.starts_with("tts-")
651            || model.starts_with("text-embedding-")
652            || model.starts_with("chatgpt-")
653            || model.starts_with("openai/")
654    }
655
656    fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
657        model.strip_prefix("openai/").unwrap_or(model)
658    }
659
660    /// Hoist OpenAI audio response data into the `content` field.
661    ///
662    /// OpenAI audio-preview models return `choices[].message.audio = { data,
663    /// transcript, format, ... }` alongside a null `content`.  Normalise this
664    /// into `choices[].message.content` as a parts array so that
665    /// [`AssistantContent::Parts`] deserialization works correctly.
666    ///
667    /// Layout emitted:
668    /// - A `{ "type": "text", "text": transcript }` part when `transcript` is
669    ///   present and non-empty.
670    /// - A `{ "type": "output_audio", "audio": { "data": …, "format": … } }` part.
671    ///
672    /// For non-audio responses this is a no-op.
673    fn transform_response(&self, body: &mut serde_json::Value) -> Result<()> {
674        transform_openai_audio_response(body)
675    }
676}
677
678/// Hoist an OpenAI audio response's `message.audio` field into `message.content`.
679///
680/// Mutates each choice in-place; returns `Ok(())` unconditionally.
681pub(crate) fn transform_openai_audio_response(body: &mut serde_json::Value) -> Result<()> {
682    use serde_json::json;
683
684    let choices = match body.get_mut("choices").and_then(|c| c.as_array_mut()) {
685        Some(c) => c,
686        None => return Ok(()),
687    };
688
689    for choice in choices {
690        let audio = match choice.pointer("/message/audio") {
691            Some(a) if !a.is_null() => a.clone(),
692            _ => continue,
693        };
694
695        let data = audio.get("data").and_then(|v| v.as_str()).unwrap_or("").to_owned();
696        let format = audio.get("format").and_then(|v| v.as_str()).unwrap_or("wav").to_owned();
697        let transcript = audio
698            .get("transcript")
699            .and_then(|v| v.as_str())
700            .unwrap_or("")
701            .to_owned();
702
703        let mut parts: Vec<serde_json::Value> = vec![];
704        if !transcript.is_empty() {
705            parts.push(json!({"type": "text", "text": transcript}));
706        }
707        if !data.is_empty() {
708            parts.push(json!({
709                "type": "output_audio",
710                "audio": {"data": data, "format": format}
711            }));
712        }
713
714        if !parts.is_empty()
715            && let Some(message) = choice.get_mut("message")
716        {
717            message["content"] = json!(parts);
718        }
719    }
720
721    Ok(())
722}
723
724/// A generic OpenAI-compatible provider (configurable base_url + bearer auth).
725pub(crate) struct OpenAiCompatibleProvider {
726    pub name: String,
727    pub base_url: String,
728    /// Environment variable name for the API key, if known.
729    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
730    pub env_var: Option<&'static str>,
731    pub model_prefixes: Vec<String>,
732    /// Provider named by the client's `model_hint`; its `name/` prefix is stripped from models.
733    pub hint_provider: Option<String>,
734}
735
736impl Provider for OpenAiCompatibleProvider {
737    fn name(&self) -> &str {
738        &self.name
739    }
740
741    fn base_url(&self) -> &str {
742        &self.base_url
743    }
744
745    fn env_var(&self) -> Option<&str> {
746        self.env_var
747    }
748
749    fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
750        Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
751    }
752
753    fn matches_model(&self, model: &str) -> bool {
754        self.model_prefixes
755            .iter()
756            .any(|prefix| model.starts_with(prefix.as_str()))
757    }
758
759    /// Strip a leading `X/` only when `X` is this provider's own name or the provider
760    /// named by the client's `model_hint`.
761    ///
762    /// This provider fronts a user-supplied `base_url`. Gateways there (OpenRouter, a
763    /// liter-llm or LiteLLM proxy) route on the prefix, so `openai/gpt-4o` must reach them
764    /// verbatim unless the caller declared via `model_hint` that the endpoint is that
765    /// provider. Only the first segment is removed.
766    fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
767        if let Some((prefix, rest)) = model.split_once('/')
768            && !rest.is_empty()
769            && (prefix == self.name || self.hint_provider.as_deref() == Some(prefix))
770        {
771            return rest;
772        }
773        model
774    }
775}
776
777/// Provider name designated by a `model_hint` (`"openai"` or `"openai/gpt-4o-mini"` -> `"openai"`).
778///
779/// Uses the same `provider/` split as [`detect_provider`].
780pub(crate) fn hint_provider_name(model_hint: &str) -> Option<String> {
781    let name = model_hint.split_once('/').map_or(model_hint, |(prefix, _)| prefix);
782    (!name.is_empty()).then(|| name.to_owned())
783}
784
785/// A data-driven provider backed by a [`ProviderConfig`] entry from providers.json.
786///
787/// Used for simple providers that are fully described by their JSON config.
788/// Complex providers (AWS Bedrock, Vertex AI, etc.) use dedicated implementations.
789///
790/// # Construction
791///
792/// Construct only via [`ConfigDrivenProvider::new`], which is intentionally
793/// `pub(crate)` — callers outside this crate must go through [`detect_provider`].
794///
795/// # `base_url` contract
796///
797/// [`Provider::base_url`] returns an empty string when the provider config has
798/// no `base_url` entry.  This is safe because [`detect_provider`] guards the
799/// `base_url.is_some()` condition before constructing a `ConfigDrivenProvider`,
800/// so a correctly-routed request will never produce an empty URL.  A manually
801/// constructed instance (hypothetically) would produce a clearly-broken URL
802/// (`/chat/completions`) that fails immediately at the HTTP layer.
803pub(crate) struct ConfigDrivenProvider {
804    config: &'static ProviderConfig,
805    aliases: &'static [String],
806    endpoint_urls: Option<&'static HashMap<String, String>>,
807}
808
809impl ConfigDrivenProvider {
810    #[cfg(test)]
811    #[must_use]
812    pub(crate) fn new(config: &'static ProviderConfig) -> Self {
813        Self {
814            config,
815            aliases: &[],
816            endpoint_urls: None,
817        }
818    }
819
820    fn from_entry(entry: &'static ProviderEntry) -> Self {
821        Self {
822            config: &entry.config,
823            aliases: &entry.aliases,
824            endpoint_urls: Some(&entry.endpoint_urls),
825        }
826    }
827}
828
829impl Provider for ConfigDrivenProvider {
830    fn name(&self) -> &str {
831        &self.config.name
832    }
833
834    fn base_url(&self) -> &str {
835        self.config.base_url.as_deref().unwrap_or("")
836    }
837
838    fn env_var(&self) -> Option<&str> {
839        self.config.auth.as_ref().and_then(|a| a.env_var.as_deref())
840    }
841
842    fn build_url(&self, endpoint_path: &str, _model: &str) -> String {
843        if endpoint_path == self.rerank_path()
844            && let Some(url) = self.endpoint_urls.and_then(|urls| urls.get("rerank"))
845        {
846            return url.clone();
847        }
848        format!("{}{}", self.base_url(), endpoint_path)
849    }
850
851    fn strip_model_prefix<'m>(&self, model: &'m str) -> &'m str {
852        match model.split_once('/') {
853            Some((prefix, rest)) if prefix == self.config.name || self.aliases.iter().any(|alias| alias == prefix) => {
854                rest
855            }
856            _ => model,
857        }
858    }
859
860    fn transform_request(&self, body: &mut serde_json::Value) -> Result<()> {
861        if let Some(mappings) = &self.config.param_mappings
862            && let Some(obj) = body.as_object_mut()
863        {
864            for (from, to) in mappings {
865                if let Some(val) = obj.remove(from.as_str()) {
866                    obj.insert(to.clone(), val);
867                }
868            }
869        }
870        Ok(())
871    }
872
873    fn auth_header<'a>(&'a self, api_key: &'a str) -> Option<(Cow<'static, str>, Cow<'a, str>)> {
874        let auth_type = self
875            .config
876            .auth
877            .as_ref()
878            .map(|a| &a.auth_type)
879            .unwrap_or(&AuthType::Bearer);
880
881        match auth_type {
882            AuthType::None => None,
883            AuthType::ApiKey => Some((Cow::Borrowed("x-api-key"), Cow::Borrowed(api_key))),
884            AuthType::Bearer | AuthType::Unknown => {
885                Some((Cow::Borrowed("Authorization"), Cow::Owned(format!("Bearer {api_key}"))))
886            }
887        }
888    }
889
890    fn matches_model(&self, model: &str) -> bool {
891        if let Some(prefixes) = &self.config.model_prefixes {
892            prefixes.iter().any(|p| model.starts_with(p.as_str()))
893        } else {
894            false
895        }
896    }
897}
898
899/// Detect which provider to use based on model name.
900///
901/// Strategy:
902/// 1. OpenAI hardcoded patterns (gpt-*, o1-*, text-embedding-*, …).
903/// 2. Anthropic: `claude-*` model names or `anthropic/` prefix.
904/// 3. Azure: `azure/` prefix.
905/// 4. Google AI Studio: `gemini/` or `google_ai/` prefix.
906/// 5. Vertex AI: `vertex_ai/` prefix.
907/// 6. AWS Bedrock: `bedrock/` prefix.
908/// 7. `"provider/"` prefix — look up the prefix in the registry.
909/// 8. Walk all registry entries and check their `model_prefixes`.
910///
911/// Returns `None` when no built-in provider matches.  The caller should fall
912/// back to a config-specified `base_url` or default to [`OpenAiProvider`].
913///
914/// Complex providers (those listed in `complex_providers` in providers.json)
915/// are excluded from config-driven routing because they require custom
916/// auth/request logic beyond simple bearer tokens.
917pub(crate) fn detect_provider(model: &str) -> Option<Box<dyn Provider>> {
918    if let Some(provider) = custom::detect_custom_provider(model) {
919        return Some(provider);
920    }
921
922    let openai = OpenAiProvider;
923    if openai.matches_model(model) {
924        return Some(Box::new(openai));
925    }
926
927    let anthropic = anthropic::AnthropicProvider::default();
928    if anthropic.matches_model(model) {
929        return Some(Box::new(anthropic));
930    }
931
932    if model.starts_with("azure/") {
933        return Some(Box::new(azure::AzureProvider::new()));
934    }
935
936    if model.starts_with("gemini/") || model.starts_with("google_ai/") {
937        return Some(Box::new(google_ai::GoogleAiProvider::default()));
938    }
939
940    if model.starts_with("vertex_ai/") {
941        return Some(Box::new(vertex::VertexAiProvider::from_env()));
942    }
943
944    if model.starts_with("bedrock/") {
945        return Some(Box::new(bedrock::BedrockProvider::from_env()));
946    }
947
948    if model.starts_with("command-") || model.starts_with("cohere/") {
949        return Some(Box::new(cohere::CohereProvider));
950    }
951
952    if model.starts_with("mistral-")
953        || model.starts_with("codestral-")
954        || model.starts_with("pixtral-")
955        || model.starts_with("mistral/")
956    {
957        return Some(Box::new(mistral::MistralProvider));
958    }
959
960    if model.starts_with("github_copilot/") {
961        return Some(Box::new(github_copilot::GithubCopilotProvider::from_env()));
962    }
963
964    let reg = match REGISTRY.as_ref() {
965        Ok(r) => r,
966        Err(_) => return None,
967    };
968
969    if let Some((prefix, _)) = model.split_once('/')
970        && let Some(entry) = reg
971            .providers
972            .iter()
973            .find(|e| e.config.name == prefix)
974            .or_else(|| reg.providers.iter().find(|e| e.aliases.iter().any(|a| a == prefix)))
975        && entry.config.base_url.is_some()
976        && !reg.complex_providers.contains(&entry.config.name)
977    {
978        return Some(Box::new(ConfigDrivenProvider::from_entry(entry)));
979    }
980
981    for entry in &reg.providers {
982        if reg.complex_providers.contains(&entry.config.name) {
983            continue;
984        }
985        if let Some(prefixes) = &entry.config.model_prefixes {
986            let matches = prefixes
987                .iter()
988                .any(|p| model.starts_with(p.as_str()) && !p.ends_with('/'));
989            if matches && entry.config.base_url.is_some() {
990                return Some(Box::new(ConfigDrivenProvider::from_entry(entry)));
991            }
992        }
993    }
994
995    None
996}
997
998/// Return all provider configs from the registry.
999///
1000/// Useful for tooling, documentation generation, or runtime enumeration.
1001/// Returns the public [`ProviderConfig`] slice (without capability flags).
1002/// To query capability flags for a specific provider use [`capabilities`].
1003pub fn all_providers() -> Result<&'static [ProviderConfig]> {
1004    Ok(&registry()?.configs)
1005}
1006
1007/// Return the set of complex provider names.
1008///
1009/// Complex providers require custom auth/routing logic beyond simple bearer
1010/// tokens (e.g. AWS Bedrock SigV4, Vertex AI OAuth2).
1011///
1012/// The returned reference points into the static registry — no allocation.
1013pub fn complex_provider_names() -> Result<&'static HashSet<String>> {
1014    Ok(&registry()?.complex_providers)
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020    use serde_json::json;
1021
1022    /// Revert line: delete the `if value < min || value > max { ... }` check inside
1023    /// `validate_sampling_param_range` (or make it always return `Ok(())`) to make
1024    /// this test fail.
1025    #[test]
1026    fn validate_sampling_param_range_rejects_out_of_range_value() {
1027        let body = json!({"temperature": 1.8});
1028        let err = validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0)
1029            .expect_err("1.8 is outside [0.0, 1.0]");
1030        assert_eq!(err.status_code(), 400);
1031        assert_eq!(
1032            err.to_string(),
1033            "bad request: temperature=1.8 is outside Anthropic's supported range [0, 1]; lower \
1034             the requested value or omit `temperature` to use the provider default"
1035        );
1036    }
1037
1038    #[test]
1039    fn validate_sampling_param_range_accepts_in_range_value() {
1040        let body = json!({"temperature": 0.5});
1041        assert!(validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0).is_ok());
1042    }
1043
1044    #[test]
1045    fn validate_sampling_param_range_ignores_absent_field() {
1046        let body = json!({"model": "claude-3-5-sonnet"});
1047        assert!(validate_sampling_param_range(&body, "temperature", "Anthropic", 0.0, 1.0).is_ok());
1048    }
1049
1050    /// Every provider that authenticates with a static API key must name the
1051    /// environment variable that key is conventionally read from.
1052    ///
1053    /// `env_var` is the sole gate in `DefaultClient::new`, and it is checked
1054    /// with `if let Some(..)` — so a provider returning `None` does not merely
1055    /// skip auto-loading, it skips the "no API key" error too.  The client is
1056    /// built with an empty key and the failure only surfaces as a 401 on the
1057    /// first request.  Bedrock, Vertex AI and GitHub Copilot legitimately
1058    /// return `None`: their credentials come from SigV4, ADC and an OAuth
1059    /// device-flow exchange respectively, not from a key variable.
1060    #[test]
1061    fn static_key_providers_declare_their_env_var() {
1062        let cases: Vec<(&str, Box<dyn Provider>)> = vec![
1063            ("OPENAI_API_KEY", Box::new(OpenAiProvider)),
1064            (
1065                "ANTHROPIC_API_KEY",
1066                Box::new(super::anthropic::AnthropicProvider::new()),
1067            ),
1068            ("COHERE_API_KEY", Box::new(super::cohere::CohereProvider)),
1069            ("MISTRAL_API_KEY", Box::new(super::mistral::MistralProvider)),
1070            (
1071                "GEMINI_API_KEY",
1072                Box::new(super::google_ai::GoogleAiProvider::default()),
1073            ),
1074            (
1075                "AZURE_OPENAI_API_KEY",
1076                Box::new(super::azure::AzureProvider::with_base_url("https://x.openai.azure.com")),
1077            ),
1078        ];
1079
1080        for (expected, provider) in cases {
1081            assert_eq!(
1082                provider.env_var(),
1083                Some(expected),
1084                "{} must declare {expected}",
1085                provider.name()
1086            );
1087        }
1088    }
1089
1090    #[test]
1091    fn minimax_regional_endpoints_are_registered() {
1092        let registry = registry().expect("registry should load");
1093        let provider = registry
1094            .providers
1095            .iter()
1096            .find(|entry| entry.config.name == "minimax")
1097            .expect("MiniMax provider should be registered");
1098
1099        assert!(provider.capabilities.reasoning);
1100        assert_eq!(
1101            provider.regional_endpoints,
1102            vec![
1103                RegionalEndpointConfig {
1104                    region: "global_en".into(),
1105                    openai_base_url: "https://api.minimax.io/v1".into(),
1106                    anthropic_base_url: "https://api.minimax.io/anthropic".into(),
1107                    docs_root: "https://platform.minimax.io/docs".into(),
1108                },
1109                RegionalEndpointConfig {
1110                    region: "cn_zh".into(),
1111                    openai_base_url: "https://api.minimaxi.com/v1".into(),
1112                    anthropic_base_url: "https://api.minimaxi.com/anthropic".into(),
1113                    docs_root: "https://platform.minimaxi.com/docs".into(),
1114                },
1115            ]
1116        );
1117    }
1118
1119    /// `OpenAiProvider::transform_request` is a no-op — the wire body is exactly what
1120    /// serde produces from the typed `Message`/`ToolMessage` API. Asserts the exact
1121    /// serialized JSON shape of a `Parts` tool result: OpenAI's chat-completions
1122    /// `content` array accepts `image_url` parts inside a tool message verbatim.
1123    #[test]
1124    fn openai_tool_result_image_part_serializes_to_exact_content_array() {
1125        use crate::types::{ContentPart, Message, ToolMessage, UserContent};
1126
1127        let message = Message::Tool(ToolMessage {
1128            content: UserContent::Parts(vec![
1129                ContentPart::text("Here is a screenshot:"),
1130                ContentPart::image_data_url("data:image/png;base64,abc123"),
1131            ]),
1132            tool_call_id: "call_shot".into(),
1133            name: None,
1134        });
1135        let mut body = json!({"model": "gpt-4o", "messages": [message]});
1136
1137        OpenAiProvider
1138            .transform_request(&mut body)
1139            .expect("transform_request should not fail");
1140
1141        assert_eq!(
1142            body["messages"][0],
1143            json!({
1144                "role": "tool",
1145                "content": [
1146                    {"type": "text", "text": "Here is a screenshot:"},
1147                    {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}}
1148                ],
1149                "tool_call_id": "call_shot"
1150            })
1151        );
1152    }
1153
1154    #[test]
1155    fn transform_response_audio_message_hoisted() {
1156        let mut body = json!({
1157            "id": "chatcmpl-audio-123",
1158            "object": "chat.completion",
1159            "created": 1700000000u64,
1160            "model": "gpt-4o-audio-preview",
1161            "choices": [{
1162                "index": 0,
1163                "message": {
1164                    "role": "assistant",
1165                    "content": null,
1166                    "audio": {
1167                        "id": "audio-abc",
1168                        "data": "aGVsbG8=",
1169                        "transcript": "hello",
1170                        "format": "wav",
1171                        "expires_at": 9999999999u64
1172                    }
1173                },
1174                "finish_reason": "stop"
1175            }]
1176        });
1177
1178        transform_openai_audio_response(&mut body).expect("transform must succeed");
1179
1180        let content = body
1181            .pointer("/choices/0/message/content")
1182            .expect("content must be present");
1183        assert!(content.is_array(), "content must be a parts array, got: {content}");
1184        let parts = content.as_array().expect("array");
1185        assert_eq!(parts.len(), 2);
1186        assert_eq!(parts[0]["type"], "text");
1187        assert_eq!(parts[0]["text"], "hello");
1188        assert_eq!(parts[1]["type"], "output_audio");
1189        assert_eq!(parts[1]["audio"]["data"], "aGVsbG8=");
1190        assert_eq!(parts[1]["audio"]["format"], "wav");
1191    }
1192
1193    #[test]
1194    fn transform_response_audio_no_op_when_no_audio_field() {
1195        let mut body = json!({
1196            "choices": [{
1197                "message": {
1198                    "role": "assistant",
1199                    "content": "plain text"
1200                }
1201            }]
1202        });
1203        let original = body.clone();
1204        transform_openai_audio_response(&mut body).expect("transform must succeed");
1205        assert_eq!(body, original, "body must be unchanged when no audio field is present");
1206    }
1207
1208    #[test]
1209    fn parse_stream_event_tolerates_trailing_metadata_event_without_id() {
1210        // ~keep OpenCode Zen/Go emits an `inference-cost` event (no `id`, empty
1211        // `choices`) right before `[DONE]`; the default parser must yield an empty
1212        // chunk rather than aborting the stream with `missing field 'id'` (#155).
1213        let payload = r#"{"choices":[],"x-opencode-type":"inference-cost","cost":"0.00001400","normalizedUsage":{"inputTokens":84,"outputTokens":8}}"#;
1214        let chunk = OpenAiProvider
1215            .parse_stream_event(payload)
1216            .expect("trailing metadata event must not error")
1217            .expect("event should decode to a chunk, not be skipped");
1218        assert!(chunk.id.is_empty());
1219        assert!(chunk.choices.is_empty());
1220    }
1221
1222    /// Revert line: delete the `if let Ok(value) = ... && let Some(error) = ...`
1223    /// block in `parse_stream_event` to make this test fail.
1224    #[test]
1225    fn parse_stream_event_surfaces_mid_stream_error_object() {
1226        // ~keep Groq answers HTTP 200 + `text/event-stream`, then aborts the
1227        // stream with an error object when a model emits a malformed tool call.
1228        // The real status lives in the payload, so it must be recovered from
1229        // there rather than from the (successful) response code.
1230        let payload = r#"{"error":{"message":"tool call validation failed","type":"invalid_request_error","code":"tool_use_failed","status_code":400}}"#;
1231        let err = OpenAiProvider
1232            .parse_stream_event(payload)
1233            .expect_err("a mid-stream error object must not decode as a chunk");
1234        assert_eq!(err.status_code(), 400);
1235        assert!(
1236            err.to_string().contains("tool call validation failed"),
1237            "the provider's own message must reach the caller, got: {err}"
1238        );
1239    }
1240
1241    /// Revert line: change the `status_code` fallback in `parse_stream_event`
1242    /// from `unwrap_or(400)` to `unwrap_or(200)` to make this test fail.
1243    #[test]
1244    fn parse_stream_event_defaults_status_when_error_object_omits_it() {
1245        // ~keep Not every provider includes `status_code` in the error object;
1246        // absent one, a 4xx is the safe reading — the request is over and it did
1247        // not succeed, so it must not be reported as a 2xx.
1248        let payload = r#"{"error":{"message":"upstream capacity exceeded"}}"#;
1249        let err = OpenAiProvider
1250            .parse_stream_event(payload)
1251            .expect_err("an error object without a status must still be an error");
1252        assert_eq!(err.status_code(), 400);
1253        assert!(err.to_string().contains("upstream capacity exceeded"));
1254    }
1255
1256    /// Revert line: delete `#[serde(default)]` from `ChatCompletionChunk::choices`
1257    /// in `types/chat.rs` to make this test fail.
1258    #[test]
1259    fn parse_stream_event_tolerates_metadata_event_without_choices() {
1260        // ~keep The #155 trailing-metadata event omits `id`; a provider may also
1261        // omit `choices` entirely on such an event. Defaulting the field keeps
1262        // the stream alive, and the error-object guard above runs first so this
1263        // tolerance cannot swallow a real failure.
1264        let payload =
1265            r#"{"object":"chat.completion.chunk","usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}"#;
1266        let chunk = OpenAiProvider
1267            .parse_stream_event(payload)
1268            .expect("a metadata event without `choices` must not error")
1269            .expect("event should decode to a chunk, not be skipped");
1270        assert!(chunk.choices.is_empty());
1271    }
1272
1273    #[test]
1274    fn parse_stream_event_parses_normal_chunk_with_id() {
1275        let payload = r#"{"id":"chatcmpl-1","object":"chat.completion.chunk","created":1700000000,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":null}]}"#;
1276        let chunk = OpenAiProvider
1277            .parse_stream_event(payload)
1278            .expect("valid chunk must parse")
1279            .expect("valid chunk must yield Some");
1280        assert_eq!(chunk.id, "chatcmpl-1");
1281        assert_eq!(chunk.choices[0].delta.content.as_deref(), Some("hi"));
1282    }
1283}