Skip to main content

rig_core/providers/openai/wire/
modality.rs

1//! Embedding, transcription, image, speech, listing, reranking, and verification wires.
2//! Each operation consumes a whole reply, using its dialect's JSON or binary format.
3//!
4//! ```
5//! use rig_core::providers::openai::{OpenAI, TEXT_EMBEDDING_3_SMALL};
6//! let wire = OpenAI::new("key").embedding(TEXT_EMBEDDING_3_SMALL, None);
7//! ```
8
9use crate::wire::Flow;
10use serde::{Deserialize, Serialize};
11
12use crate::embeddings;
13use crate::error::EncodeError;
14use crate::error::ProviderError;
15use crate::model::{ModelInfo, ModelList};
16use crate::operation::{
17    Embedding, ModelListing, ModelPage, Rerank as RerankOp, Transcription, Verify as VerifyOp,
18};
19use crate::providers::internal::wire::classify_untyped_line;
20use crate::providers::openai::embedding::Usage;
21use crate::providers::openai::embedding::{
22    CompatibleEmbeddingResponse, EncodingFormat, model_dimensions_from_identifier,
23};
24use crate::transcription::TranscriptionRequest;
25use crate::wire::{
26    Body, Capabilities, Decoder, Descriptor, DocumentDecoder, Encoded, Framing, Mode, Out, Wire,
27    WireEvent, WireFrame,
28};
29
30use super::OpenAIConfig;
31
32/// Paired request and response formats for image generation.
33#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
34pub enum ImageBody {
35    /// `{model, prompt, size}`, answered with `data[].b64_json`.
36    #[default]
37    OpenAi,
38    /// xAI: `{model, prompt, response_format, aspect_ratio}` and no `size`,
39    /// answered with `data[].b64_json` and no `created`.
40    Xai,
41    /// `{model_name, prompt, height, width}`, answered with `images[].image`.
42    Hyperbolic,
43    /// `{model, prompt, width, height}`, answered with base64 strings in `images`.
44    Venice,
45    /// `{inputs, parameters: {width, height}}`, answered with raw image bytes.
46    /// The model is addressed through the URL path.
47    HuggingFace,
48    /// MiniMax: `{model, prompt, response_format: "base64"}`, with `image-01`'s
49    /// size as `width` and `height` unless an `aspect_ratio` replaces it, answered
50    /// with `data.image_base64[]` beside a `base_resp` status that can report
51    /// a failure under HTTP 200.
52    MiniMax,
53}
54
55/// Which body a speech endpoint takes.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum SpeechBody {
58    /// OpenAI: `{model, input, voice, speed}`.
59    #[default]
60    OpenAi,
61    /// xAI: `{text, voice_id, language}`, with `eve` as the default voice.
62    Xai,
63    /// Hyperbolic: `{language, speaker, text, speed}`, answered with
64    /// `{"audio": "<base64>"}` rather than the audio bytes themselves.
65    ///
66    /// It addresses this endpoint by *language*, so the identifier a caller
67    /// passes as the model is the language tag (`"EN"`).
68    Hyperbolic,
69    /// MiniMax: `{model, text, stream, output_format, voice_setting: {voice_id,
70    /// speed}}`, answered with hex-encoded audio under `data.audio` beside a
71    /// `base_resp` status that can report a failure under HTTP 200.
72    MiniMax,
73}
74
75/// Which body a transcription endpoint takes.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum TranscriptionBody {
78    /// OpenAI: a `multipart/form-data` upload with the audio as a file part
79    /// beside `model`, `language`, `prompt` and `temperature`.
80    Multipart,
81    /// OpenRouter: a JSON body whose audio rides base64-encoded under
82    /// `input_audio`, with its container format beside it
83    /// (`{"input_audio": {"data": "…", "format": "mp3"}, "model": …}`).
84    /// The gateway's speech-to-text route serves only this shape, and has no
85    /// top-level `prompt` field at all.
86    InputAudioJson,
87}
88
89/// Which field a dialect takes an embedding width in.
90#[derive(Clone, Copy, Debug, PartialEq, Eq)]
91pub enum DimensionsField {
92    /// The OpenAI-compatible `dimensions` field.
93    Dimensions,
94    /// Mistral's `output_dimension`.
95    OutputDimension,
96    /// The server ignores any width field, so none is sent (`llama-server`
97    /// reads no such field and would answer 200 with the native width).
98    Ignored,
99}
100
101impl DimensionsField {
102    /// The body field a requested width goes in, or `None` when the dialect
103    /// reads no width field at all.
104    ///
105    /// The encoder puts a width in this field and a refusal names it, so
106    /// both spell it from here rather than from two matching literals.
107    pub const fn name(self) -> Option<&'static str> {
108        match self {
109            Self::Dimensions => Some("dimensions"),
110            Self::OutputDimension => Some("output_dimension"),
111            Self::Ignored => None,
112        }
113    }
114}
115
116/// Which widths a request may name for one embedding model.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118pub enum AcceptedWidths {
119    /// The model emits one width and reads no width field, so any value but
120    /// its own native width is a request for a parameter the provider does
121    /// not accept there, and is refused as a request error.
122    Fixed,
123    /// The model truncates to any width in `min..=max`, and anything else is
124    /// refused with [`requirement`](Self::Range::requirement).
125    Range {
126        /// Narrowest width the provider honours.
127        min: usize,
128        /// Widest width the provider honours.
129        max: usize,
130        /// Static error clause describing the accepted bounds.
131        /// Must agree with `min` and `max`.
132        requirement: &'static str,
133    },
134}
135
136/// One embedding model's width contract: the width it returns unasked, and
137/// the widths it will honour when asked.
138///
139/// Stated per model rather than per dialect because a dialect serves models
140/// of different widths, and a model's default is not always its maximum.
141#[derive(Clone, Copy, Debug, PartialEq, Eq)]
142#[non_exhaustive]
143pub struct ModelWidth {
144    /// The model identifier, as the `model` field spells it.
145    pub model: &'static str,
146    /// Default width reported when no width is requested, or `None` if unknown.
147    /// Unknown widths report zero as the embedding model's `ndims`.
148    pub default: Option<usize>,
149    /// The widths a request may name.
150    pub accepted: AcceptedWidths,
151}
152
153/// Reranking endpoint policy. An empty [`Self::path`] disables reranking.
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155#[non_exhaustive]
156pub struct RerankQuirks {
157    /// The rerank path, or empty when the dialect offers none.
158    pub path: &'static str,
159    /// Most documents the provider accepts in one request.
160    pub max_documents: usize,
161    /// Whether the model is a body field.
162    pub sends_model_field: bool,
163}
164
165impl RerankQuirks {
166    /// The signal for a dialect with no reranking endpoint.
167    pub const fn unsupported() -> Self {
168        Self {
169            path: "",
170            max_documents: 0,
171            sends_model_field: true,
172        }
173    }
174}
175
176/// What a dialect's embeddings endpoint accepts.
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178#[non_exhaustive]
179pub struct EmbeddingQuirks {
180    /// Most inputs the provider embeds in one request.
181    pub max_documents: usize,
182    /// Whether a successful reply must carry usage.
183    pub requires_usage: bool,
184    /// Whether the provider accepts `encoding_format`.
185    pub supports_encoding_format: bool,
186    /// Whether the provider accepts `user`.
187    pub supports_user: bool,
188    /// Whether the model is a body field (false for Azure, which addresses a
189    /// deployment through the URL).
190    pub sends_model_field: bool,
191    /// Which field a requested width goes in.
192    pub dimensions: DimensionsField,
193    /// Model width contracts used for capability reporting and request validation.
194    /// Consulted before the shared OpenAI model-width table.
195    pub widths: &'static [ModelWidth],
196    /// The `requirement` clause refusing a declared width of zero, or
197    /// `None` for a dialect that lets zero through as rig's own "unknown"
198    /// sentinel rather than a claim.
199    pub refuse_zero_width: Option<&'static str>,
200}
201
202impl EmbeddingQuirks {
203    /// OpenAI's own embeddings contract, which most dialects inherit.
204    pub const fn openai() -> Self {
205        Self {
206            max_documents: 1024,
207            requires_usage: true,
208            supports_encoding_format: true,
209            supports_user: true,
210            sends_model_field: true,
211            dimensions: DimensionsField::Dimensions,
212            // Shared OpenAI model widths are resolved separately.
213            widths: &[],
214            // Zero represents an unknown width, not a requested dimension.
215            refuse_zero_width: None,
216        }
217    }
218}
219
220impl super::SubRoute {
221    /// Whether this sub-provider serves the endpoints that address the model
222    /// through the URL (transcription, image generation).
223    pub fn serves_model_routed_endpoints(&self) -> bool {
224        matches!(self, Self::HFInference)
225    }
226}
227
228impl OpenAIConfig {
229    /// Set the `api-version` Azure's speech endpoint is versioned by.
230    pub fn with_audio_api_version(mut self, api_version: impl Into<String>) -> Self {
231        self.audio_api_version = Some(api_version.into());
232        self
233    }
234
235    /// The embeddings wire for `model`.
236    pub(crate) fn embedding(&self, model: impl Into<String>, ndims: Option<usize>) -> Embeddings {
237        Embeddings::new(self.clone(), model, ndims)
238    }
239
240    /// The rerank wire for `model`.
241    pub(crate) fn rerank(&self, model: impl Into<String>) -> Rerank {
242        Rerank::new(self.clone(), model)
243    }
244
245    /// The transcription wire for `model`.
246    pub(crate) fn transcription(&self, model: impl Into<String>) -> Transcriptions {
247        Transcriptions::new(self.clone(), model)
248    }
249
250    /// The model-listing wire.
251    pub(crate) fn models(&self) -> Models {
252        Models::new(self.clone())
253    }
254
255    /// The credential-check wire.
256    pub(crate) fn verify(&self) -> Verify {
257        Verify::new(self.clone())
258    }
259
260    /// The image-generation wire for `model`.
261    #[cfg(feature = "image")]
262    pub(crate) fn image_generation(&self, model: impl Into<String>) -> Images {
263        Images::new(self.clone(), model)
264    }
265
266    /// The speech wire for `model`.
267    #[cfg(feature = "audio")]
268    pub(crate) fn audio_generation(&self, model: impl Into<String>) -> Speech {
269        Speech::new(self.clone(), model)
270    }
271
272    /// The `api-version` a speech request carries.
273    #[cfg(feature = "audio")]
274    pub(crate) fn speech_api_version(&self) -> Option<&str> {
275        self.audio_api_version
276            .as_deref()
277            .or(self.api_version.as_deref())
278    }
279
280    /// Resolve a fixed or model-addressed modality URL.
281    /// Return an error if the selected sub-route does not serve model-routed endpoints.
282    pub(crate) fn modality_uri(
283        &self,
284        endpoint: &str,
285        fixed: &'static str,
286        model: &str,
287    ) -> Result<String, String> {
288        if !self.dialect.quirks.model_is_modality_path {
289            return Ok(self.uri(fixed, self.deployment(model)));
290        }
291        let route = self.route();
292        if !route.serves_model_routed_endpoints() {
293            return Err(format!(
294                "{endpoint} endpoint is not supported yet for {route}"
295            ));
296        }
297        Ok(format!(
298            "{}/{}",
299            self.base_url.trim_end_matches('/'),
300            model.trim_start_matches('/')
301        ))
302    }
303}
304
305/// The width contract of Mistral's embedding models.
306///
307/// `mistral-embed` is fixed at 1024 and reads no width field: Mistral
308/// answers any other value with an error rather than truncating, so a
309/// request naming one is refused before it is built. Codestral Embed is
310/// configurable up to 3072 and takes its width as `output_dimension`.
311///
312/// The dated aliases are listed beside their rolling names because a caller
313/// pinning `mistral-embed-2312` gets the same model, and a model absent from
314/// this table reports `ndims() == 0`.
315pub(super) const MISTRAL_EMBEDDING_WIDTHS: &[ModelWidth] = &[
316    ModelWidth {
317        model: crate::providers::mistral::embedding::MISTRAL_EMBED,
318        default: Some(1_024),
319        accepted: AcceptedWidths::Fixed,
320    },
321    ModelWidth {
322        model: "mistral-embed-2312",
323        default: Some(1_024),
324        accepted: AcceptedWidths::Fixed,
325    },
326    ModelWidth {
327        model: crate::providers::mistral::embedding::CODESTRAL_EMBED,
328        // Configurable with no documented native width, so a handle that
329        // names none reports 0 rather than inventing one.
330        default: None,
331        accepted: AcceptedWidths::Range {
332            // Mistral documents only a ceiling. The floor is rig's own
333            // "unknown" sentinel, which never reaches the wire.
334            min: 0,
335            max: 3_072,
336            requirement: "to be at most 3072 for Codestral Embed",
337        },
338    },
339    ModelWidth {
340        model: "codestral-embed-2505",
341        default: None,
342        accepted: AcceptedWidths::Range {
343            min: 0,
344            max: 3_072,
345            requirement: "to be at most 3072 for Codestral Embed",
346        },
347    },
348];
349
350/// Doubleword embedding widths: 32 through 4096, defaulting to 4096.
351/// Validate both bounds locally because out-of-range requests can be clamped or
352/// inconsistently rejected by the service.
353pub(super) const DOUBLEWORD_EMBEDDING_WIDTHS: &[ModelWidth] = &[ModelWidth {
354    model: crate::providers::doubleword::QWEN3_EMBEDDING_8B,
355    default: Some(4_096),
356    accepted: AcceptedWidths::Range {
357        min: 32,
358        max: 4_096,
359        requirement: "to be between 32 and 4096",
360    },
361}];
362
363/// Encode an authenticated JSON POST with whole-response framing.
364pub(crate) fn json_post(
365    provider: &OpenAIConfig,
366    path: &str,
367    deployment: Option<&str>,
368    body: &serde_json::Value,
369) -> Result<Encoded, EncodeError> {
370    json_post_to(provider, provider.uri(path, deployment), body)
371}
372
373/// [`json_post`] against an already-resolved URL, for the endpoints whose
374/// URL is derived rather than a fixed path under the base.
375fn json_post_to(
376    provider: &OpenAIConfig,
377    uri: String,
378    body: &serde_json::Value,
379) -> Result<Encoded, EncodeError> {
380    let bytes = serde_json::to_vec(body)?;
381    let builder = http::Request::post(uri).header("Content-Type", "application/json");
382    encoded(provider, builder, Body::Bytes(bytes))
383}
384
385/// The `GET` whose status is the answer, for the two endpoints that send no
386/// body: the model catalogue and the credential check.
387fn get(provider: &OpenAIConfig, path: &str) -> Result<Encoded, EncodeError> {
388    encoded(
389        provider,
390        http::Request::get(provider.uri(path, None)),
391        Body::empty(),
392    )
393}
394
395/// Authenticate and build a request, then apply its modality envelope hook.
396/// Return construction or hook errors. Use whole-response framing and the
397/// dialect's request-ID header.
398pub(crate) fn encoded(
399    provider: &OpenAIConfig,
400    builder: http::request::Builder,
401    body: Body,
402) -> Result<Encoded, EncodeError> {
403    let mut request = provider.authenticate(builder).body(body)?;
404    if let Some(envelope) = provider
405        .dialect
406        .quirks
407        .hooks
408        .and_then(|hooks| hooks.modality_envelope)
409    {
410        envelope(provider, &mut request)?;
411    }
412    Ok(Encoded::new(request, Framing::Whole)
413        .with_request_id_header(provider.dialect.request_id_header))
414}
415
416/// Refuse an embeddings request parameter the dialect does not accept.
417fn unsupported_parameter(provider: &str, parameter: &str) -> EncodeError {
418    EncodeError::request(format!(
419        "{provider} embeddings do not support the `{parameter}` parameter"
420    ))
421}
422
423/// The embeddings wire.
424#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
425pub struct Embeddings {
426    /// Which provider, and how to reach it.
427    pub provider: OpenAIConfig,
428    /// The embedding model.
429    pub model: String,
430    /// The width the caller asked for, when they named one rather than
431    /// taking the model's default.
432    pub ndims: Option<usize>,
433    /// The encoding the caller asked the provider to answer in.
434    pub encoding_format: Option<EncodingFormat>,
435    /// The end-user identifier the provider attributes the call to.
436    pub user: Option<String>,
437}
438
439impl Embeddings {
440    /// The embeddings wire for `model`.
441    pub fn new(provider: OpenAIConfig, model: impl Into<String>, ndims: Option<usize>) -> Self {
442        Self {
443            provider,
444            model: model.into(),
445            ndims,
446            encoding_format: None,
447            user: None,
448        }
449    }
450
451    /// Ask the provider to answer in `encoding_format`.
452    pub fn with_encoding_format(mut self, encoding_format: EncodingFormat) -> Self {
453        self.encoding_format = Some(encoding_format);
454        self
455    }
456
457    /// Attribute the call to an end user.
458    pub fn with_user(mut self, user: impl Into<String>) -> Self {
459        self.user = Some(user.into());
460        self
461    }
462
463    /// This model's width contract on this dialect, or `None` for a model
464    /// the dialect does not document.
465    fn model_width(&self) -> Option<&'static ModelWidth> {
466        self.provider
467            .dialect
468            .quirks
469            .embedding
470            .widths
471            .iter()
472            .find(|width| width.model == self.model)
473    }
474
475    /// Resolve width from the caller, dialect table, then shared model table.
476    /// Return zero when all are absent.
477    fn resolved_ndims(&self) -> usize {
478        self.ndims
479            .or_else(|| self.model_width().and_then(|width| width.default))
480            .or_else(|| model_dimensions_from_identifier(&self.model))
481            .unwrap_or_default()
482    }
483
484    /// Validate declared widths against the dialect's zero-width and model policies.
485    /// Return a request error for unsupported widths; unknown models are unchecked.
486    fn refuse_unhonourable_width(&self) -> Result<(), EncodeError> {
487        let quirks = &self.provider.dialect.quirks.embedding;
488        let provider = self.provider.dialect.name;
489        let invalid = |requirement, parameter| {
490            EncodeError::request(format!(
491                "{provider} embeddings require `{parameter}` {requirement}"
492            ))
493        };
494        // A dialect that reads no width field has nothing to refuse: the
495        // caller's number never reaches the wire, and the shared driver
496        // catches the disagreement against the reply instead.
497        let Some(parameter) = quirks.dimensions.name() else {
498            return Ok(());
499        };
500        let Some(declared) = self.ndims else {
501            return Ok(());
502        };
503        if declared == 0 {
504            return match quirks.refuse_zero_width {
505                Some(requirement) => Err(invalid(requirement, parameter)),
506                None => Ok(()),
507            };
508        }
509        // A model the dialect does not document: the caller's width is the
510        // only width there is, so it goes out unvalidated and the API rules.
511        let Some(width) = self.model_width() else {
512            return Ok(());
513        };
514        // Native widths require no truncation parameter.
515        if width.default == Some(declared) {
516            return Ok(());
517        }
518        match width.accepted {
519            AcceptedWidths::Fixed => Err(unsupported_parameter(provider, parameter)),
520            AcceptedWidths::Range { min, max, .. } if (min..=max).contains(&declared) => Ok(()),
521            AcceptedWidths::Range { requirement, .. } => Err(invalid(requirement, parameter)),
522        }
523    }
524
525    /// The width to send, in the field this dialect spells it with.
526    ///
527    /// OpenAI's legacy Ada model does not accept a width at all, and
528    /// `llama-server` reads no width field, so neither is sent one.
529    fn requested_width(&self) -> Option<(&'static str, usize)> {
530        let field = self.provider.dialect.quirks.embedding.dimensions.name()?;
531        if self.model == crate::providers::openai::embedding::TEXT_EMBEDDING_ADA_002 {
532            return None;
533        }
534        // Unknown widths are metadata sentinels, not request parameters.
535        let ndims = match self.resolved_ndims() {
536            0 => return None,
537            ndims => ndims,
538        };
539        // At a documented model's native width, send nothing: that width is
540        // what the model emits unasked, so the field would only restate the
541        // default and the vector is identical either way.
542        if self
543            .model_width()
544            .is_some_and(|width| width.default == Some(ndims))
545        {
546            return None;
547        }
548        Some((field, ndims))
549    }
550}
551
552/// Decode embedding vectors and usage, enforcing the dialect's usage requirement.
553#[derive(Default)]
554pub struct EmbeddingsDecoder {
555    /// Whether this dialect's reply must carry usage.
556    requires_usage: bool,
557    provider: &'static str,
558    model: String,
559}
560
561impl<'id> Decoder<'id, Embedding> for EmbeddingsDecoder {
562    type Event = CompatibleEmbeddingResponse;
563
564    fn classify(&self, frame: WireFrame) -> WireEvent<Self::Event> {
565        classify_untyped_line(frame.as_str().as_bytes())
566    }
567
568    fn decode(
569        &mut self,
570        event: Self::Event,
571        out: Out<'id, Embedding>,
572    ) -> Result<Flow, ProviderError> {
573        if event.usage.is_none() && self.requires_usage {
574            return Err(ProviderError::Response(format!(
575                "{} embedding response omitted required usage",
576                self.provider
577            )));
578        }
579        let usage = event
580            .usage
581            .as_ref()
582            .map(Usage::to_normalized)
583            .unwrap_or_default();
584        let vectors = event.data.into_iter().map(|datum| datum.embedding);
585        let model = if event.model.is_empty() {
586            self.model.clone()
587        } else {
588            event.model
589        };
590        Ok(out.end(embeddings::EmbeddingResponse {
591            model: Some(model),
592            usage,
593            ..embeddings::EmbeddingResponse::from_vectors(vectors)
594        }))
595    }
596}
597
598impl Wire for Embeddings {
599    type Op = Embedding;
600    type Payload = crate::wire::Encoded;
601    type Frame = crate::wire::WireFrame;
602    type Event = CompatibleEmbeddingResponse;
603    type Decoder<'id> = EmbeddingsDecoder;
604    type Reassembler = crate::wire::document::Unreassembled;
605
606    /// Only an explicit nonzero declaration permits reply-width mismatch checks.
607    fn describe(&self) -> Descriptor<'_> {
608        Descriptor::new(self.provider.dialect.name)
609            .model(self.model.as_str())
610            .capabilities(
611                Capabilities::embedding(
612                    self.provider.dialect.quirks.embedding.max_documents,
613                    self.resolved_ndims(),
614                )
615                .declaring(self.ndims),
616            )
617    }
618
619    fn encode(&self, request: Vec<String>, _mode: Mode) -> Result<Encoded, EncodeError> {
620        let quirks = &self.provider.dialect.quirks.embedding;
621        // Base64 vectors are not decoded anywhere, so asking for them would
622        // answer 200 with a payload rig cannot read.
623        if self.encoding_format == Some(EncodingFormat::Base64) {
624            return Err(EncodeError::request(format!(
625                "Rig cannot decode {} embedding responses encoded as `base64`",
626                self.provider.dialect.name
627            )));
628        }
629        if self.encoding_format.is_some() && !quirks.supports_encoding_format {
630            return Err(unsupported_parameter(
631                self.provider.dialect.name,
632                "encoding_format",
633            ));
634        }
635        if self.user.is_some() && !quirks.supports_user {
636            return Err(unsupported_parameter(self.provider.dialect.name, "user"));
637        }
638        self.refuse_unhonourable_width()?;
639
640        let mut body = serde_json::json!({ "input": request });
641        let Some(object) = body.as_object_mut() else {
642            return Err(EncodeError::request(
643                "embedding request body must be an object",
644            ));
645        };
646        if quirks.sends_model_field {
647            object.insert("model".to_owned(), serde_json::json!(self.model));
648        }
649        if let Some((field, ndims)) = self.requested_width() {
650            object.insert(field.to_owned(), serde_json::json!(ndims));
651        }
652        if let Some(encoding_format) = self.encoding_format {
653            object.insert(
654                "encoding_format".to_owned(),
655                serde_json::to_value(encoding_format)?,
656            );
657        }
658        if let Some(user) = &self.user {
659            object.insert("user".to_owned(), serde_json::json!(user));
660        }
661
662        json_post(
663            &self.provider,
664            self.provider.dialect.quirks.embeddings_path,
665            self.provider.deployment(&self.model),
666            &body,
667        )
668    }
669
670    fn decoder<'id>(&self) -> Self::Decoder<'id> {
671        EmbeddingsDecoder {
672            requires_usage: self.provider.dialect.quirks.embedding.requires_usage,
673            provider: self.provider.dialect.name,
674            model: self.model.clone(),
675        }
676    }
677}
678
679/// Transcription requests encoded as multipart or input-audio JSON.
680#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
681pub struct Transcriptions {
682    /// Which provider, and how to reach it.
683    pub provider: OpenAIConfig,
684    /// Transcription model or Azure deployment identifier.
685    pub model: String,
686}
687
688impl Transcriptions {
689    /// The transcription wire for `model`.
690    pub fn new(provider: OpenAIConfig, model: impl Into<String>) -> Self {
691        Self {
692            provider,
693            model: model.into(),
694        }
695    }
696
697    /// OpenAI's multipart upload: the audio as a file part beside the
698    /// per-request options.
699    fn multipart_body(&self, request: TranscriptionRequest) -> Result<Body, EncodeError> {
700        use crate::http_client::MultipartForm;
701        use crate::http_client::multipart::Part;
702
703        let mut form = MultipartForm::new();
704        // Azure addresses its deployment through the URL rather than the form.
705        if self.provider.deployment(&self.model).is_none() {
706            form = form.text("model", self.model.clone());
707        }
708        form = form.part(Part::bytes("file", request.data).filename(request.filename));
709        if let Some(language) = request.language {
710            form = form.text("language", language);
711        }
712        if let Some(prompt) = request.prompt {
713            form = form.text("prompt", prompt);
714        }
715        if let Some(temperature) = request.temperature {
716            form = form.text("temperature", temperature.to_string());
717        }
718        if let Some(additional_params) = request.additional_params {
719            for (name, value) in additional_params_object(&additional_params)? {
720                match value {
721                    // Arrays go as one `name[]` field per item, the form
722                    // encoding these endpoints read for lists such as
723                    // `timestamp_granularities`.
724                    serde_json::Value::Array(items) => {
725                        for item in items {
726                            form = form.text(format!("{name}[]"), form_text(item));
727                        }
728                    }
729                    other => form = form.text(name.clone(), form_text(other)),
730                }
731            }
732        }
733        Ok(Body::Multipart(form))
734    }
735
736    /// Encode base64 audio under `input_audio`, inferring format from the filename.
737    /// Reject top-level prompts and non-object additional parameters.
738    fn input_audio_body(&self, request: TranscriptionRequest) -> Result<Body, EncodeError> {
739        use base64::Engine;
740
741        if request.prompt.is_some() {
742            return Err(EncodeError::request(std::io::Error::new(
743                std::io::ErrorKind::InvalidInput,
744                "OpenRouter STT does not support a top-level prompt field. \
745                     Provider-specific prompt options can be passed via `additional_params`. \
746                     Example: {\"provider\": {\"options\": {\"<provider>\": {\"prompt\": \"<text>\"}}}}",
747            )));
748        }
749
750        let mut body = serde_json::Map::new();
751        body.insert("model".to_owned(), serde_json::json!(self.model));
752        body.insert(
753            "input_audio".to_owned(),
754            serde_json::json!({
755                "data": base64::engine::general_purpose::STANDARD.encode(&request.data),
756                "format": audio_format_of(&request.filename),
757            }),
758        );
759        if let Some(language) = request.language {
760            body.insert("language".to_owned(), serde_json::json!(language));
761        }
762        if let Some(temperature) = request.temperature {
763            body.insert("temperature".to_owned(), serde_json::json!(temperature));
764        }
765        if let Some(additional_params) = request.additional_params {
766            for (name, value) in additional_params_object(&additional_params)? {
767                body.insert(name.clone(), value.clone());
768            }
769        }
770        Ok(Body::Bytes(serde_json::to_vec(
771            &serde_json::Value::Object(body),
772        )?))
773    }
774}
775
776/// A form field's text: strings as they are, without JSON quotation marks,
777/// and other values as JSON.
778fn form_text(value: &serde_json::Value) -> String {
779    match value {
780        serde_json::Value::String(value) => value.clone(),
781        other => other.to_string(),
782    }
783}
784
785/// A transcription request's `additional_params`, as an object.
786fn additional_params_object(
787    params: &serde_json::Value,
788) -> Result<&serde_json::Map<String, serde_json::Value>, EncodeError> {
789    params.as_object().ok_or_else(|| {
790        EncodeError::request(std::io::Error::new(
791            std::io::ErrorKind::InvalidInput,
792            "additional transcription parameters must be a JSON object",
793        ))
794    })
795}
796
797/// Infer the audio container from a case-insensitive filename extension.
798/// Unknown or absent extensions default to `wav`.
799fn audio_format_of(filename: &str) -> &'static str {
800    let extension = std::path::Path::new(filename)
801        .extension()
802        .and_then(std::ffi::OsStr::to_str)
803        .map(str::to_ascii_lowercase);
804    match extension.as_deref() {
805        Some("mp3") => "mp3",
806        Some("flac") => "flac",
807        Some("m4a") => "m4a",
808        Some("ogg") => "ogg",
809        Some("webm") => "webm",
810        Some("aac") => "aac",
811        _ => "wav",
812    }
813}
814
815impl Wire for Transcriptions {
816    type Op = Transcription;
817    type Payload = crate::wire::Encoded;
818    type Frame = crate::wire::WireFrame;
819    type Event = crate::providers::openai::transcription::TranscriptionResponse;
820    type Decoder<'id> = DocumentDecoder<Transcription, Self::Event>;
821    type Reassembler = crate::wire::document::Unreassembled;
822
823    fn describe(&self) -> Descriptor<'_> {
824        Descriptor::new(self.provider.dialect.name).model(self.model.as_str())
825    }
826
827    fn encode(&self, request: TranscriptionRequest, _mode: Mode) -> Result<Encoded, EncodeError> {
828        let uri = self
829            .provider
830            .modality_uri(
831                "transcription",
832                self.provider.dialect.quirks.transcription_path,
833                &self.model,
834            )
835            .map_err(EncodeError::request)?;
836        let builder = http::Request::post(uri);
837        let (builder, body) = match self.provider.dialect.quirks.transcription_body {
838            TranscriptionBody::Multipart => (builder, self.multipart_body(request)?),
839            TranscriptionBody::InputAudioJson => (
840                builder.header(http::header::CONTENT_TYPE, "application/json"),
841                self.input_audio_body(request)?,
842            ),
843        };
844        encoded(&self.provider, builder, body)
845    }
846
847    fn decoder<'id>(&self) -> Self::Decoder<'id> {
848        DocumentDecoder::new(
849            |body| classify_untyped_line(body.as_bytes()),
850            |event| event.normalize(),
851        )
852    }
853}
854
855/// The image-generation wire.
856#[cfg(feature = "image")]
857#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
858pub struct Images {
859    /// Which provider, and how to reach it.
860    pub provider: OpenAIConfig,
861    /// The image model.
862    pub model: String,
863}
864
865#[cfg(feature = "image")]
866impl Images {
867    /// The image-generation wire for `model`.
868    pub fn new(provider: OpenAIConfig, model: impl Into<String>) -> Self {
869        Self {
870            provider,
871            model: model.into(),
872        }
873    }
874}
875
876/// The image-generation decoder.
877///
878/// Carries the dialect's [`ImageBody`] because the request shape names the
879/// reply shape: most of this family answers with a JSON envelope, and
880/// Hugging Face's router answers with the image bytes themselves.
881#[cfg(feature = "image")]
882#[derive(Default)]
883pub struct ImagesDecoder {
884    /// Which reply shape this dialect answers with.
885    body: ImageBody,
886}
887
888/// One generated image, as every dialect on this wire returns it.
889#[cfg(feature = "image")]
890#[derive(Debug, Clone, Serialize, Deserialize)]
891pub struct ImageDatum {
892    /// The image, base64-encoded.
893    pub b64_json: String,
894}
895
896/// Base64 image represented as a string or an object with an `image` field.
897#[cfg(feature = "image")]
898#[derive(Debug, Clone, Serialize, Deserialize)]
899#[serde(untagged)]
900pub enum ImagesReplyImage {
901    /// Hyperbolic: `{"image": "<base64>"}`.
902    Keyed {
903        /// The image, base64-encoded.
904        image: String,
905    },
906    /// Venice: the base64 payload itself.
907    Bare(String),
908}
909
910#[cfg(feature = "image")]
911impl ImagesReplyImage {
912    /// The image's base64 payload, whichever form the dialect sent.
913    pub fn base64(&self) -> &str {
914        match self {
915            Self::Keyed { image } => image,
916            Self::Bare(image) => image,
917        }
918    }
919}
920
921/// JSON image reply accepting `data` and `images` arrays with optional metadata.
922#[cfg(feature = "image")]
923#[derive(Debug, Clone, Serialize, Deserialize)]
924pub struct ImagesReply {
925    /// The generated images, as OpenAI and xAI key them.
926    #[serde(default)]
927    pub data: Vec<ImageDatum>,
928    /// The generated images, as Hyperbolic and Venice key them.
929    #[serde(default)]
930    pub images: Vec<ImagesReplyImage>,
931    /// Whatever else the dialect sent (`created`, Venice's `id`/`timing`, and
932    /// any field this build does not model), so the raw payload loses
933    /// nothing.
934    #[serde(flatten)]
935    pub extra: serde_json::Map<String, serde_json::Value>,
936}
937
938#[cfg(feature = "image")]
939impl ImagesReply {
940    /// The first image's base64 payload, whichever key the dialect used.
941    pub fn first_base64(&self) -> Option<&str> {
942        self.data
943            .first()
944            .map(|image| image.b64_json.as_str())
945            .or_else(|| self.images.first().map(ImagesReplyImage::base64))
946            .filter(|encoded| !encoded.is_empty())
947    }
948}
949
950/// Generated images in a decoded JSON envelope or as raw bytes.
951#[cfg(feature = "image")]
952#[derive(Debug, Clone)]
953pub enum ImagesEvent {
954    /// A JSON envelope, as OpenAI, xAI and Hyperbolic answer.
955    Json(ImagesReply),
956    /// The image bytes themselves, with no envelope at all.
957    Raw(Vec<u8>),
958    /// A MiniMax reply body, decoded once its status envelope is checked.
959    MiniMax(Vec<u8>),
960}
961
962#[cfg(feature = "image")]
963impl<'id> Decoder<'id, crate::operation::ImageGeneration> for ImagesDecoder {
964    type Event = ImagesEvent;
965
966    fn classify(&self, frame: WireFrame) -> WireEvent<Self::Event> {
967        match self.body {
968            // Preserve raw image bytes even when framing recognized valid UTF-8.
969            ImageBody::HuggingFace => WireEvent::Known(ImagesEvent::Raw(match frame {
970                WireFrame::Text(text) => text.into_bytes(),
971                WireFrame::Bytes(bytes) => bytes,
972            })),
973            ImageBody::MiniMax => WireEvent::Known(ImagesEvent::MiniMax(match frame {
974                WireFrame::Text(text) => text.into_bytes(),
975                WireFrame::Bytes(bytes) => bytes,
976            })),
977            ImageBody::OpenAi | ImageBody::Xai | ImageBody::Hyperbolic | ImageBody::Venice => {
978                classify_untyped_line(frame.as_str().as_bytes()).map(ImagesEvent::Json)
979            }
980        }
981    }
982
983    fn decode(
984        &mut self,
985        event: Self::Event,
986        out: Out<'id, crate::operation::ImageGeneration>,
987    ) -> Result<Flow, ProviderError> {
988        use crate::image_generation::ImageGenerationResponse;
989        use base64::Engine;
990
991        let reply = match event {
992            // The image is already the payload, and the reply is not a
993            // document, so `raw` stays null rather than restating the bytes.
994            ImagesEvent::Raw(image) => {
995                return Ok(out.end(ImageGenerationResponse::new(image)));
996            }
997            ImagesEvent::MiniMax(body) => {
998                let reply = crate::providers::minimax::decode_reply::<
999                    crate::providers::minimax::image_generation::ImageGenerationReply,
1000                >(&body)?;
1001                return Ok(out.end(reply.into_response()?));
1002            }
1003            ImagesEvent::Json(reply) => reply,
1004        };
1005        let Some(encoded) = reply.first_base64() else {
1006            return Err(ProviderError::Response("missing image data".to_owned()));
1007        };
1008        let image = match base64::prelude::BASE64_STANDARD.decode(encoded) {
1009            Ok(image) => image,
1010            Err(error) => {
1011                return Err(ProviderError::Response(error.to_string()));
1012            }
1013        };
1014        Ok(out.end(ImageGenerationResponse::new(image)))
1015    }
1016}
1017
1018#[cfg(feature = "image")]
1019impl Wire for Images {
1020    type Op = crate::operation::ImageGeneration;
1021    type Payload = crate::wire::Encoded;
1022    type Frame = crate::wire::WireFrame;
1023    type Event = ImagesEvent;
1024    type Decoder<'id> = ImagesDecoder;
1025    type Reassembler = crate::wire::document::Unreassembled;
1026
1027    fn describe(&self) -> Descriptor<'_> {
1028        Descriptor::new(self.provider.dialect.name).model(self.model.as_str())
1029    }
1030
1031    fn encode(
1032        &self,
1033        request: crate::image_generation::ImageGenerationRequest,
1034        _mode: Mode,
1035    ) -> Result<Encoded, EncodeError> {
1036        if !request.images.is_empty() {
1037            return Err(EncodeError::request(
1038                "this image generation endpoint takes no reference images",
1039            ));
1040        }
1041        let mut body = match self.provider.dialect.quirks.image_body {
1042            // `response_format` is deliberately absent: it is no longer part
1043            // of OpenAI's request schema, which rejects it before it even
1044            // looks at the model. A compatible endpoint that still takes the
1045            // field gets it through `additional_params`.
1046            ImageBody::OpenAi => serde_json::json!({
1047                "model": self.model,
1048                "prompt": request.prompt,
1049                "size": format!("{}x{}", request.width, request.height),
1050            }),
1051            // xAI takes no `size` and answers with a URL unless asked for
1052            // base64, which is the only form this wire decodes.
1053            ImageBody::Xai => serde_json::json!({
1054                "model": self.model,
1055                "prompt": request.prompt,
1056                "response_format": "b64_json",
1057                "aspect_ratio": "1:1",
1058            }),
1059            ImageBody::Hyperbolic => serde_json::json!({
1060                "model_name": self.model,
1061                "prompt": request.prompt,
1062                "height": request.height,
1063                "width": request.width,
1064            }),
1065            ImageBody::Venice => serde_json::json!({
1066                "model": self.model,
1067                "prompt": request.prompt,
1068                "width": request.width,
1069                "height": request.height,
1070            }),
1071            ImageBody::MiniMax => {
1072                crate::providers::minimax::image_generation::request_body(&self.model, &request)?
1073            }
1074            // The model is addressed through the URL, not the body.
1075            ImageBody::HuggingFace => serde_json::json!({
1076                "inputs": request.prompt,
1077                "parameters": {
1078                    "width": request.width,
1079                    "height": request.height,
1080                },
1081            }),
1082        };
1083        // Merged last, so a caller can reach the endpoint's other parameters
1084        // and override what is derived above.
1085        if let Some(additional_params) = request.additional_params {
1086            crate::json_utils::merge_inplace(&mut body, additional_params);
1087        }
1088
1089        let uri = self
1090            .provider
1091            .modality_uri(
1092                "image generation",
1093                self.provider.dialect.quirks.image_generation_path,
1094                &self.model,
1095            )
1096            .map_err(EncodeError::request)?;
1097        json_post_to(&self.provider, uri, &body)
1098    }
1099
1100    fn decoder<'id>(&self) -> Self::Decoder<'id> {
1101        ImagesDecoder {
1102            body: self.provider.dialect.quirks.image_body,
1103        }
1104    }
1105}
1106
1107/// The speech wire.
1108#[cfg(feature = "audio")]
1109#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1110pub struct Speech {
1111    /// Which provider, and how to reach it.
1112    pub provider: OpenAIConfig,
1113    /// The speech model.
1114    pub model: String,
1115}
1116
1117#[cfg(feature = "audio")]
1118impl Speech {
1119    /// The speech wire for `model`.
1120    pub fn new(provider: OpenAIConfig, model: impl Into<String>) -> Self {
1121        Self {
1122            provider,
1123            model: model.into(),
1124        }
1125    }
1126}
1127
1128/// Decode raw audio or the dialect's base64 JSON envelope.
1129#[cfg(feature = "audio")]
1130#[derive(Default)]
1131pub struct SpeechDecoder {
1132    /// Which reply shape this dialect answers with.
1133    body: SpeechBody,
1134}
1135
1136/// Hyperbolic's speech reply: base64 in a JSON envelope rather than the
1137/// audio bytes themselves.
1138#[cfg(feature = "audio")]
1139#[derive(Debug, Clone, Serialize, Deserialize)]
1140pub struct SpeechReply {
1141    /// The audio, base64-encoded.
1142    pub audio: String,
1143}
1144
1145#[cfg(feature = "audio")]
1146impl<'id> Decoder<'id, crate::operation::AudioGeneration> for SpeechDecoder {
1147    type Event = Vec<u8>;
1148
1149    fn classify(&self, frame: WireFrame) -> WireEvent<Self::Event> {
1150        // Interpretation selects raw audio or JSON decoding from the dialect.
1151        WireEvent::Known(match frame {
1152            WireFrame::Text(text) => text.into_bytes(),
1153            WireFrame::Bytes(bytes) => bytes,
1154        })
1155    }
1156
1157    fn decode(
1158        &mut self,
1159        event: Self::Event,
1160        out: Out<'id, crate::operation::AudioGeneration>,
1161    ) -> Result<Flow, ProviderError> {
1162        use base64::Engine;
1163
1164        let audio = match self.body {
1165            // OpenAI and xAI answer with the audio itself.
1166            SpeechBody::OpenAi | SpeechBody::Xai => event,
1167            SpeechBody::MiniMax => {
1168                let reply = crate::providers::minimax::decode_reply::<
1169                    crate::providers::minimax::audio_generation::SpeechReply,
1170                >(&event)?;
1171                return Ok(out.end(reply.into_response()?));
1172            }
1173            // Hyperbolic wraps it, base64-encoded, in a JSON envelope.
1174            SpeechBody::Hyperbolic => {
1175                let reply = match serde_json::from_slice::<SpeechReply>(&event) {
1176                    Ok(reply) => reply,
1177                    Err(error) => {
1178                        return Err(ProviderError::Response(error.to_string()));
1179                    }
1180                };
1181                match base64::prelude::BASE64_STANDARD.decode(&reply.audio) {
1182                    Ok(audio) => audio,
1183                    Err(error) => {
1184                        return Err(ProviderError::Response(error.to_string()));
1185                    }
1186                }
1187            }
1188        };
1189        Ok(out.end(crate::audio_generation::AudioGenerationResponse::new(audio)))
1190    }
1191}
1192
1193#[cfg(feature = "audio")]
1194impl Wire for Speech {
1195    type Op = crate::operation::AudioGeneration;
1196    type Payload = crate::wire::Encoded;
1197    type Frame = crate::wire::WireFrame;
1198    type Event = Vec<u8>;
1199    type Decoder<'id> = SpeechDecoder;
1200    type Reassembler = crate::wire::document::Unreassembled;
1201
1202    fn describe(&self) -> Descriptor<'_> {
1203        Descriptor::new(self.provider.dialect.name).model(self.model.as_str())
1204    }
1205
1206    fn encode(
1207        &self,
1208        request: crate::audio_generation::AudioGenerationRequest,
1209        _mode: Mode,
1210    ) -> Result<Encoded, EncodeError> {
1211        let mut additional_params = request.additional_params;
1212        let mut body = match self.provider.dialect.quirks.speech_body {
1213            SpeechBody::OpenAi => serde_json::json!({
1214                "model": self.model,
1215                "input": request.text,
1216                "voice": request.voice,
1217                "speed": request.speed,
1218            }),
1219            // xAI requires a default voice when the caller leaves it empty.
1220            SpeechBody::Xai => serde_json::json!({
1221                "text": request.text,
1222                "voice_id": if request.voice.is_empty() { "eve" } else { request.voice.as_str() },
1223                "language": "en",
1224            }),
1225            // Hyperbolic addresses this endpoint by language, so the
1226            // identifier the caller passes as the model IS the language tag.
1227            SpeechBody::Hyperbolic => serde_json::json!({
1228                "language": self.model,
1229                "speaker": request.voice,
1230                "text": request.text,
1231                "speed": request.speed,
1232            }),
1233            // Only the non-streaming reply's hex audio is decoded.
1234            SpeechBody::MiniMax => serde_json::json!({
1235                "model": self.model,
1236                "text": request.text,
1237                "stream": false,
1238                "output_format": "hex",
1239                "voice_setting": {
1240                    "voice_id": request.voice,
1241                    "speed": request.speed,
1242                },
1243            }),
1244        };
1245        // A caller's `voice_setting` adds to the derived one, so setting the
1246        // volume keeps the voice.
1247        if let Some(derived) = body
1248            .get_mut("voice_setting")
1249            .and_then(serde_json::Value::as_object_mut)
1250            && let Some(params) = additional_params
1251                .as_mut()
1252                .and_then(serde_json::Value::as_object_mut)
1253            && let Some(serde_json::Value::Object(caller)) = params.get("voice_setting")
1254        {
1255            derived.extend(caller.clone());
1256            params.shift_remove("voice_setting");
1257        }
1258        // Caller parameters take precedence, including response format and instructions.
1259        if let Some(additional_params) = additional_params {
1260            crate::json_utils::merge_inplace(&mut body, additional_params);
1261        }
1262
1263        // Azure versions its speech endpoint separately from every other
1264        // route, so this one request carries its own `api-version`.
1265        let uri = self.provider.uri_versioned(
1266            self.provider.dialect.quirks.audio_generation_path,
1267            self.provider.deployment(&self.model),
1268            self.provider.speech_api_version(),
1269        );
1270        json_post_to(&self.provider, uri, &body)
1271    }
1272
1273    fn decoder<'id>(&self) -> Self::Decoder<'id> {
1274        SpeechDecoder {
1275            body: self.provider.dialect.quirks.speech_body,
1276        }
1277    }
1278}
1279
1280/// The model-listing wire.
1281#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1282pub struct Models {
1283    /// Which provider, and how to reach it.
1284    pub provider: OpenAIConfig,
1285}
1286
1287impl Models {
1288    /// The model-listing wire.
1289    pub fn new(provider: OpenAIConfig) -> Self {
1290        Self { provider }
1291    }
1292}
1293
1294/// Model-list entry with required `id` and optional metadata.
1295/// Context length prefers `context_window`, then `context_length`, then
1296/// `max_context_length`. Top-level output limits take precedence over `top_provider`.
1297#[derive(Debug, Deserialize)]
1298pub struct ModelEntry {
1299    pub id: String,
1300    #[serde(default)]
1301    pub name: Option<String>,
1302    #[serde(default)]
1303    pub description: Option<String>,
1304    /// Mistral labels the model kind `type` (`base`, `fine-tuned`).
1305    #[serde(default, rename = "type")]
1306    pub kind: Option<String>,
1307    #[serde(default)]
1308    pub created: Option<u64>,
1309    #[serde(default)]
1310    pub owned_by: Option<String>,
1311    #[serde(default)]
1312    pub context_window: Option<u32>,
1313    #[serde(default)]
1314    pub context_length: Option<u32>,
1315    #[serde(default)]
1316    pub max_context_length: Option<u32>,
1317    #[serde(default)]
1318    pub max_completion_tokens: Option<u32>,
1319    #[serde(default)]
1320    pub top_provider: Option<TopProvider>,
1321}
1322
1323/// OpenRouter's per-entry routing block. Only the output ceiling is read;
1324/// the rest of the block is routing detail [`ModelInfo`] has no slot for.
1325#[derive(Debug, Deserialize)]
1326pub struct TopProvider {
1327    #[serde(default)]
1328    pub max_completion_tokens: Option<u32>,
1329}
1330
1331impl From<ModelEntry> for ModelInfo {
1332    fn from(entry: ModelEntry) -> Self {
1333        let mut model = ModelInfo::from_id(entry.id);
1334        model.name = entry.name;
1335        model.description = entry.description;
1336        model.r#type = entry.kind;
1337        model.created_at = entry.created;
1338        model.owned_by = entry.owned_by;
1339        model.context_length = entry
1340            .context_window
1341            .or(entry.context_length)
1342            .or(entry.max_context_length);
1343        model.max_output_tokens = entry.max_completion_tokens.or_else(|| {
1344            entry
1345                .top_provider
1346                .and_then(|provider| provider.max_completion_tokens)
1347        });
1348        model
1349    }
1350}
1351
1352/// The `{ "data": [...] }` envelope.
1353#[derive(Debug, Deserialize)]
1354pub struct ModelsReply {
1355    #[serde(default)]
1356    pub data: Vec<ModelEntry>,
1357}
1358
1359impl Wire for Models {
1360    type Op = ModelListing;
1361    type Payload = crate::wire::Encoded;
1362    type Frame = crate::wire::WireFrame;
1363    type Event = ModelsReply;
1364    type Decoder<'id> = DocumentDecoder<ModelListing, Self::Event>;
1365    type Reassembler = crate::wire::document::Unreassembled;
1366
1367    fn describe(&self) -> Descriptor<'_> {
1368        Descriptor::new(self.provider.dialect.name)
1369    }
1370
1371    /// The catalogue is one page, so a cursor never follows.
1372    fn encode(&self, _cursor: Option<String>, _mode: Mode) -> Result<Encoded, EncodeError> {
1373        get(&self.provider, self.provider.dialect.quirks.models_path)
1374    }
1375
1376    /// Decode a complete model catalogue without pagination.
1377    fn decoder<'id>(&self) -> Self::Decoder<'id> {
1378        DocumentDecoder::new(
1379            |body| classify_untyped_line(body.as_bytes()),
1380            |event| {
1381                let models = event.data.into_iter().map(ModelInfo::from).collect();
1382                Ok(ModelPage {
1383                    models: ModelList::new(models),
1384                    next: None,
1385                })
1386            },
1387        )
1388    }
1389}
1390
1391/// Rerank documents with `{model, query, documents, top_n}` requests.
1392/// The dialect must configure a nonempty reranking path.
1393#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1394pub struct Rerank {
1395    /// Which provider, and how to reach it.
1396    pub provider: OpenAIConfig,
1397    /// The reranker model.
1398    pub model: String,
1399    /// Return only the `top_n` highest-scoring documents, when the caller
1400    /// asked for a cut.
1401    pub top_n: Option<usize>,
1402}
1403
1404impl Rerank {
1405    /// The rerank wire for `model`.
1406    pub fn new(provider: OpenAIConfig, model: impl Into<String>) -> Self {
1407        Self {
1408            provider,
1409            model: model.into(),
1410            top_n: None,
1411        }
1412    }
1413
1414    /// Ask the server to return only the `top_n` highest-scoring documents.
1415    pub fn with_top_n(mut self, top_n: usize) -> Self {
1416        self.top_n = Some(top_n);
1417        self
1418    }
1419}
1420
1421/// Scored input document accepting `relevance_score` or `score` as its score key.
1422#[derive(Debug, Clone, Serialize, Deserialize)]
1423pub struct RerankResultEntry {
1424    /// Which input document this scored.
1425    pub index: usize,
1426    /// The score.
1427    #[serde(alias = "score")]
1428    pub relevance_score: f64,
1429    /// Present only on servers that echo the document back; llama.cpp does
1430    /// not on this path.
1431    #[serde(default, alias = "text")]
1432    pub document: Option<String>,
1433}
1434
1435/// What a rerank reply reports besides its ranking.
1436#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1437pub struct RerankUsage {
1438    /// Tokens the query and documents cost.
1439    #[serde(default)]
1440    pub prompt_tokens: u64,
1441    /// Total tokens, as the provider reported them.
1442    #[serde(default)]
1443    pub total_tokens: u64,
1444}
1445
1446/// The rerank reply.
1447#[derive(Debug, Clone, Serialize, Deserialize)]
1448pub struct RerankReply {
1449    /// The model the server ranked with, when it named one.
1450    #[serde(default)]
1451    pub model: Option<String>,
1452    /// The ranking.
1453    pub results: Vec<RerankResultEntry>,
1454    /// What it cost.
1455    #[serde(default)]
1456    pub usage: Option<RerankUsage>,
1457}
1458
1459impl Wire for Rerank {
1460    type Op = RerankOp;
1461    type Payload = crate::wire::Encoded;
1462    type Frame = crate::wire::WireFrame;
1463    type Event = RerankReply;
1464    type Decoder<'id> = DocumentDecoder<RerankOp, Self::Event>;
1465    type Reassembler = crate::wire::document::Unreassembled;
1466
1467    fn describe(&self) -> Descriptor<'_> {
1468        Descriptor::new(self.provider.dialect.name)
1469            .model(self.model.as_str())
1470            .capabilities(Capabilities::rerank(
1471                self.provider.dialect.quirks.rerank.max_documents,
1472            ))
1473    }
1474
1475    fn encode(
1476        &self,
1477        request: crate::operation::RerankRequest,
1478        _mode: Mode,
1479    ) -> Result<Encoded, EncodeError> {
1480        let quirks = &self.provider.dialect.quirks.rerank;
1481        // An empty path explicitly disables reranking.
1482        if quirks.path.is_empty() {
1483            return Err(EncodeError::request(format!(
1484                "{} offers no reranking endpoint",
1485                self.provider.dialect.name
1486            )));
1487        }
1488        let mut body = serde_json::json!({
1489            "query": request.query,
1490            "documents": request.documents,
1491        });
1492        let Some(object) = body.as_object_mut() else {
1493            return Err(EncodeError::request(
1494                "rerank request body must be an object",
1495            ));
1496        };
1497        if quirks.sends_model_field {
1498            object.insert("model".to_owned(), serde_json::json!(self.model));
1499        }
1500        if let Some(top_n) = self.top_n {
1501            object.insert("top_n".to_owned(), serde_json::json!(top_n));
1502        }
1503
1504        json_post(
1505            &self.provider,
1506            quirks.path,
1507            self.provider.deployment(&self.model),
1508            &body,
1509        )
1510    }
1511
1512    fn decoder<'id>(&self) -> Self::Decoder<'id> {
1513        DocumentDecoder::new(
1514            |body| classify_untyped_line(body.as_bytes()),
1515            |event| {
1516                let usage = event
1517                    .usage
1518                    .map(|usage| crate::completion::Usage {
1519                        input_tokens: Some(usage.prompt_tokens),
1520                        total_tokens: Some(usage.total_tokens),
1521                        ..Default::default()
1522                    })
1523                    .unwrap_or_default();
1524                let results = event
1525                    .results
1526                    .into_iter()
1527                    .map(|result| crate::rerank::RerankResult {
1528                        index: result.index,
1529                        document: result.document,
1530                        relevance_score: result.relevance_score,
1531                    })
1532                    .collect();
1533                Ok(crate::rerank::RerankResponse {
1534                    model: event.model,
1535                    usage,
1536                    ..crate::rerank::RerankResponse::new(results)
1537                })
1538            },
1539        )
1540    }
1541}
1542
1543/// The credential-check wire: a `GET` whose status is the answer.
1544#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1545pub struct Verify {
1546    /// Which provider, and how to reach it.
1547    pub provider: OpenAIConfig,
1548}
1549
1550impl Verify {
1551    /// The credential-check wire.
1552    pub fn new(provider: OpenAIConfig) -> Self {
1553        Self { provider }
1554    }
1555}
1556
1557pub use crate::operation::VerifyDecoder;
1558
1559impl Wire for Verify {
1560    type Op = VerifyOp;
1561    type Payload = crate::wire::Encoded;
1562    type Frame = crate::wire::WireFrame;
1563    type Event = ();
1564    type Decoder<'id> = VerifyDecoder;
1565    type Reassembler = crate::wire::document::Unreassembled;
1566
1567    fn describe(&self) -> Descriptor<'_> {
1568        Descriptor::new(self.provider.dialect.name)
1569    }
1570
1571    fn encode(&self, _request: (), _mode: Mode) -> Result<Encoded, EncodeError> {
1572        let path = self.provider.dialect.quirks.verify_path;
1573        if path.is_empty() {
1574            return Err(EncodeError::request(format!(
1575                "{} offers no endpoint that checks a credential without consuming tokens",
1576                self.provider.dialect.name
1577            )));
1578        }
1579        get(&self.provider, path)
1580    }
1581
1582    fn decoder<'id>(&self) -> Self::Decoder<'id> {
1583        VerifyDecoder
1584    }
1585}
1586
1587#[cfg(test)]
1588mod tests;