Skip to main content

rig_core/providers/openai/
wire.rs

1//! OpenAI-compatible configurations, dialect policies, and endpoint wires.
2//! A [`Dialect`](crate::providers::openai::wire::Dialect) selects request
3//! and response policies; [`OpenAIConfig`](crate::providers::openai::OpenAIConfig)
4//! holds credentials and overrides. An
5//! [`OpenAI`](crate::providers::openai::OpenAI) client puts
6//! the configuration on a transport and builds each endpoint's model.
7//!
8//! ```
9//! use rig_core::providers::openai::{OpenAIConfig, Route, wire::OpenAiWire};
10//!
11//! let openai = OpenAIConfig::new("key").with_route(Route::Chat).client();
12//! assert!(matches!(openai.completion("gpt-5.2").wire, OpenAiWire::Chat(_)));
13//! ```
14
15use serde::{Deserialize, Serialize};
16
17use crate::client::env::{self, EnvError};
18use crate::wire::Secret;
19
20use super::responses_api::SystemInstructionsPlacement;
21use super::responses_api::wire::Responses;
22
23mod auth;
24pub(crate) mod chat;
25mod dialects;
26/// The merge that assembles a streamed provider object from its fragments.
27pub(crate) mod dto;
28mod modality;
29mod route;
30
31use auth::default_user_agent;
32pub use auth::{Auth, AuthAlternative, CallerIdentity, Identity};
33pub use chat::Chat;
34pub use dialects::*;
35pub use modality::{
36    AcceptedWidths, DimensionsField, EmbeddingQuirks, Embeddings, EmbeddingsDecoder, ImageBody,
37    ModelEntry, ModelWidth, Models, ModelsReply, Rerank, RerankQuirks, RerankReply,
38    RerankResultEntry, RerankUsage, SpeechBody, TranscriptionBody, Transcriptions, Verify,
39    VerifyDecoder,
40};
41pub use route::{OpenAiDecoder, OpenAiEvent, OpenAiReassembler, OpenAiWire, Route};
42
43#[cfg(feature = "image")]
44pub use modality::{ImageDatum, Images, ImagesDecoder, ImagesEvent, ImagesReply};
45#[cfg(feature = "audio")]
46pub use modality::{Speech, SpeechDecoder};
47#[cfg(feature = "audio")]
48pub(crate) use modality::{encoded, json_post};
49
50/// Backend selected through the Hugging Face router.
51#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
52pub enum SubRoute {
53    /// Hugging Face's own inference backend: the only one that serves
54    /// transcription and image generation.
55    #[default]
56    HFInference,
57    /// Together AI, through the router.
58    Together,
59    /// SambaNova, through the router.
60    SambaNova,
61    /// Fireworks AI, which addresses models by a qualified id.
62    Fireworks,
63    /// Hyperbolic, through the router.
64    Hyperbolic,
65    /// Nebius, through the router.
66    Nebius,
67    /// Novita, through the router.
68    Novita,
69    /// A route this build does not name.
70    Custom(String),
71}
72
73impl SubRoute {
74    /// The router's slug for this sub-provider.
75    pub fn slug(&self) -> &str {
76        match self {
77            Self::HFInference => "hf-inference/models",
78            Self::Together => "together",
79            Self::SambaNova => "sambanova",
80            Self::Fireworks => "fireworks-ai",
81            Self::Hyperbolic => "hyperbolic",
82            Self::Nebius => "nebius",
83            Self::Novita => "novita",
84            Self::Custom(route) => route,
85        }
86    }
87
88    /// Qualify Fireworks model identifiers unless already prefixed.
89    /// Return other sub-routes' identifiers unchanged.
90    pub fn model_identifier(&self, model: &str) -> String {
91        const FIREWORKS_PREFIX: &str = "accounts/fireworks/models/";
92        match self {
93            Self::Fireworks if !model.starts_with(FIREWORKS_PREFIX) => {
94                format!("{FIREWORKS_PREFIX}{model}")
95            }
96            _ => model.to_owned(),
97        }
98    }
99}
100
101impl std::fmt::Display for SubRoute {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str(self.slug())
104    }
105}
106
107impl From<&str> for SubRoute {
108    fn from(route: &str) -> Self {
109        Self::Custom(route.to_owned())
110    }
111}
112
113impl From<String> for SubRoute {
114    fn from(route: String) -> Self {
115        Self::Custom(route)
116    }
117}
118
119/// How a dialect addresses a model.
120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
121pub enum Routing {
122    /// Resolve the endpoint under the base URL and send the model in the body.
123    Path,
124    /// Azure: the model is a *deployment* in the URL
125    /// (`{base}/openai/deployments/{model}/chat/completions?api-version=…`)
126    /// and the body carries no `model` field.
127    AzureDeployment,
128}
129
130/// How a dialect spells the output-token cap.
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum OutputCap {
133    /// `max_tokens`, for every endpoint not observed to reject it.
134    Legacy,
135    /// `max_completion_tokens` for OpenAI's reasoning families, which answer
136    /// a `max_tokens` request with `Unsupported parameter`. Scoped to the
137    /// model, because this same wire reaches compatible servers that know
138    /// only the legacy field.
139    OpenAiReasoningFamilies,
140}
141
142/// The tool schema a dialect's strict function calling takes.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144#[non_exhaustive]
145pub enum StrictSchema {
146    /// OpenAI's: every object closed and every property required.
147    OpenAi,
148    /// DeepSeek's: every object closed and every property required,
149    /// keywords beside a `$ref` kept, a `$ref` in `anyOf` written out, a
150    /// one-member `allOf` merged, a type list naming `object` or `array`
151    /// split into an `anyOf`, and a plain string's `format` outside the five
152    /// it names dropped. A tool whose schema it cannot express, such as a
153    /// map, is sent without `strict`.
154    DeepSeek,
155}
156
157/// Dialect-specific transformation of the serialized chat request.
158#[derive(Clone, Copy, Debug, PartialEq, Eq)]
159#[non_exhaustive]
160pub enum BodyRewrite {
161    /// Send the OpenAI-compatible body unchanged.
162    None,
163    /// Hugging Face's router: qualify the model identifier for sub-providers
164    /// that demand one (Fireworks).
165    HuggingFaceRouter,
166    /// DeepSeek: forced tool choices dropped while the model thinks. Its
167    /// reasoning field is [`Quirks::reasoning_field`].
168    DeepSeek,
169    /// Mira's gateway: content-part arrays flattened to strings.
170    Mira,
171    /// Perplexity: text-only content arrays sent as one string.
172    Perplexity,
173    /// Mistral: the forced tool choice relaxed to `auto` beside a structured
174    /// response format, and its own content chunks.
175    Mistral,
176    /// llama.cpp: refuse a specific-function tool choice, which
177    /// `llama-server` silently treats as `auto`.
178    LlamaCpp,
179    /// Moonshot: refuse a specific-function tool choice and coerce
180    /// `required` to `auto` with a steering message.
181    Moonshot,
182    /// OpenRouter: model ids name the upstream vendor (`anthropic/...`),
183    /// which decides what a request carries.
184    OpenRouter,
185    /// Ollama's OpenAI-compatible API: a refusal of `num_ctx` and
186    /// `options`, which only the native route can send.
187    Ollama,
188}
189
190/// Request restrictions and response handling for a Responses dialect.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub enum ResponsesContract {
193    /// Standard Responses behavior with independently configured instruction placement.
194    OpenAi,
195    /// xAI's `/v1/responses`: it answers a success with its error envelope
196    /// as the whole body and publishes a finished tool call at
197    /// `output_item.done`, and its native structured output does not
198    /// compose with tool calls. The stream's own `error` event is not this:
199    /// that is protocol on every dialect and the decoder always reads it.
200    Xai,
201    /// Always-streamed Codex responses with optional content-type and envelope fields.
202    /// Requests omit sampling controls, storage, metadata, and structured output.
203    Codex,
204}
205
206/// What a dialect's Responses endpoint is: where it lives, where the system
207/// preamble goes, and which contract it speaks.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209#[non_exhaustive]
210pub struct ResponsesQuirks {
211    /// The endpoint path, appended to the base URL.
212    pub path: &'static str,
213    /// Where Rig's system instructions go in the request. Not part of
214    /// [`Self::contract`]: OpenAI's contract is served with all three
215    /// placements (OpenAI's own `instructions`, Copilot's and OpenRouter's
216    /// `system` items in `input`).
217    pub system_instructions: SystemInstructionsPlacement,
218    /// The role a system message takes in `input`.
219    pub system_role: &'static str,
220    /// Which contract this dialect's endpoint speaks.
221    pub contract: ResponsesContract,
222    /// Whether newly constructed Responses wires normalize tools for strict validation.
223    pub strict_tools_by_default: bool,
224    /// Whether the endpoint returns reasoning ciphertext through
225    /// `include: ["reasoning.encrypted_content"]`, which the wire then asks
226    /// for whenever a request reasons or stores nothing. Where it does not,
227    /// a reasoning item carries its own text and replays with `store: false`.
228    pub reasoning_ciphertext: bool,
229}
230
231impl ResponsesQuirks {
232    /// OpenAI's own Responses contract.
233    pub const fn openai() -> Self {
234        Self {
235            path: "/responses",
236            system_instructions: SystemInstructionsPlacement::Instructions,
237            system_role: "system",
238            contract: ResponsesContract::OpenAi,
239            strict_tools_by_default: false,
240            reasoning_ciphertext: true,
241        }
242    }
243}
244
245/// Optional executable extensions to the shared OpenAI dialect.
246///
247/// Store this value in a `static`: equality means the same extension definition,
248/// not equality of function addresses (which code generation can merge or duplicate).
249/// This lets named dialect persistence reject replaced hooks without interpreting
250/// their behavior or pretending arbitrary callbacks can be serialized.
251#[derive(Debug)]
252pub struct DialectHooks {
253    /// Derive a default endpoint from a credential. `None` uses the dialect's
254    /// static URL. Called only at construction, never on credential replacement.
255    pub default_endpoint: Option<fn(&str) -> Option<String>>,
256    /// Select the default route for a model, unless configuration chose a route.
257    pub model_route: Option<fn(&str) -> Route>,
258    /// Apply the completion envelope after shared authentication and identity.
259    /// Called once by either completion encoder; builder errors remain attached
260    /// and are returned when the encoder finishes the request.
261    pub completion_envelope: Option<CompletionEnvelope>,
262    /// Stamp the envelope every modality request (embeddings, listing,
263    /// verification, transcription, images, speech) carries, on the finished
264    /// request. Runs after shared authentication, so a hook may replace the
265    /// credential header rather than add a second one.
266    pub modality_envelope: Option<ModalityEnvelope>,
267}
268
269/// A dialect's modality-request headers, applied to the built request.
270pub type ModalityEnvelope =
271    fn(&OpenAIConfig, &mut http::Request<crate::wire::Body>) -> Result<(), http::Error>;
272
273/// A dialect's completion headers, applied to the authenticated request builder.
274pub type CompletionEnvelope = fn(
275    &OpenAIConfig,
276    &crate::completion::CompletionRequest,
277    http::request::Builder,
278) -> http::request::Builder;
279
280impl PartialEq for DialectHooks {
281    fn eq(&self, other: &Self) -> bool {
282        std::ptr::eq(self, other)
283    }
284}
285
286impl Eq for DialectHooks {}
287
288/// Everything about a dialect that is not its identity: paths, capability
289/// flags, and the one body rewrite it needs.
290///
291/// `#[non_exhaustive]` because the constants live in this crate and a new
292/// quirk must not be a breaking change for a host that stored a wire.
293#[derive(Clone, Copy, Debug, PartialEq, Eq)]
294#[non_exhaustive]
295pub struct Quirks {
296    /// Provider-owned extensions for defaults and completion headers.
297    pub hooks: Option<&'static DialectHooks>,
298    /// How the dialect authenticates.
299    pub auth: Auth,
300    /// How the dialect addresses a model.
301    pub routing: Routing,
302    /// Which completion endpoint
303    /// [`OpenAI::completion`](crate::providers::openai::OpenAI::completion) builds: the
304    /// dialect's flagship. Chat Completions is the one endpoint every
305    /// dialect serves, so it is the baseline; OpenAI itself, xAI and ChatGPT
306    /// serve `/responses` as their primary API and say so.
307    pub completion_route: Route,
308    /// Whether a model's catalog row picks
309    /// [`OpenAI::completion`](crate::providers::openai::OpenAI::completion)'s
310    /// route when neither the configuration nor a hook does, for a gateway
311    /// that serves each model on the API models.dev lists for it.
312    pub routes_by_catalog: bool,
313    /// The chat-completions path, relative to the base URL.
314    pub completion_path: &'static str,
315    /// The embeddings path.
316    pub embeddings_path: &'static str,
317    /// The model-listing path.
318    pub models_path: &'static str,
319    /// The path a credential check hits. Empty means the dialect offers no
320    /// check that does not consume tokens (Azure, Perplexity, Copilot).
321    pub verify_path: &'static str,
322    /// The transcription path.
323    pub transcription_path: &'static str,
324    /// The image-generation path.
325    pub image_generation_path: &'static str,
326    /// The speech path.
327    pub audio_generation_path: &'static str,
328    /// Whether `tools`/`tool_choice` reach the provider at all.
329    pub supports_tools: bool,
330    /// The prefix a wire with [`strict_tools`](Chat::strict_tools) puts
331    /// before [`completion_path`](Self::completion_path), for a dialect that
332    /// serves strict function calling apart. A base URL that already ends
333    /// with it gets no second one.
334    pub strict_tools_prefix: Option<&'static str>,
335    /// The tool schema the dialect's strict function calling takes.
336    pub strict_schema: StrictSchema,
337    /// Whether `output_schema` maps to `response_format`.
338    pub supports_response_format: bool,
339    /// Whether to send `response_format` with tools before any tool result.
340    /// When false, defer the format until a tool result exists to avoid suppressing calls.
341    pub response_format_with_tools: bool,
342    /// Whether this server honours an image inside a `role:"tool"` message.
343    pub supports_image_tool_results: bool,
344    /// Where the server takes `system` messages that come after the
345    /// conversation begins.
346    pub later_system: crate::completion::LaterSystem,
347    /// Whether a streaming request asks for the usage chunk through
348    /// `stream_options`.
349    pub stream_include_usage: bool,
350    /// Whether a stream's `[DONE]` ends the turn when no finish reason
351    /// arrived, as a tool call when the reply holds one and a stop otherwise.
352    /// Off, such a stream is truncated.
353    pub done_without_finish_reason: bool,
354    /// How the dialect spells the output-token cap.
355    pub output_cap: OutputCap,
356    /// Whether to consult upstream-native finish reasons when normalized ones are absent.
357    pub native_finish_reason: bool,
358    /// The finish reasons the dialect documents beyond the ones every Chat
359    /// dialect shares (`stop`, `length`, `tool_calls`, `content_filter` and
360    /// their compatible spellings). Any other reason fails the turn.
361    pub finishes: &'static [(&'static str, crate::completion::FinishReason)],
362    /// The field rebuilt reasoning goes under, which every assistant message
363    /// then carries, empty when the turn has none. `None` sends
364    /// reasoning only under the field it arrived in.
365    pub reasoning_field: Option<&'static str>,
366    /// Whether `completion_tokens_details.reasoning_tokens` can be trusted as
367    /// a part of `completion_tokens`, as OpenAI documents it. A dialect whose
368    /// replies report more reasoning than completion leaves the count
369    /// unreported, so [`Usage`](crate::completion::Usage) never reports more
370    /// reasoning than output.
371    pub reliable_reasoning_count: bool,
372    /// Whether a bare JSON string is accepted as a text-only completion reply.
373    pub accepts_bare_string_reply: bool,
374    /// Whether document and file inputs may use provider file IDs.
375    pub accepts_file_ids: bool,
376    /// Whether the credential is a session token that expires within the
377    /// hour, so a saved copy soon stops working.
378    pub short_lived_key: bool,
379    /// The rewrite this dialect applies to the serialized chat body.
380    pub rewrite: BodyRewrite,
381    /// Paths that strip a trailing `/v1` from the configured base URL.
382    pub root_relative_routes: &'static [&'static str],
383    /// Whether the model is the modality endpoint's *path* rather than a
384    /// body field. Hugging Face's router addresses transcription and image
385    /// generation as `/{model}`; everyone else uses a fixed path.
386    pub model_is_modality_path: bool,
387    /// Which body the image endpoint takes.
388    pub image_body: ImageBody,
389    /// Which body the transcription endpoint takes.
390    pub transcription_body: TranscriptionBody,
391    /// Which body the speech endpoint takes.
392    pub speech_body: SpeechBody,
393    /// What the embeddings endpoint accepts.
394    pub embedding: EmbeddingQuirks,
395    /// What the rerank endpoint accepts.
396    pub rerank: RerankQuirks,
397    /// A second environment variable naming the base URL, kept because the
398    /// provider documents both spellings.
399    pub base_url_env_alias: Option<&'static str>,
400    /// The environment variable naming the account a credential belongs to,
401    /// sent as `ChatGPT-Account-Id`.
402    pub account_id_env: Option<&'static str>,
403    /// Instructions this gateway expects every turn to carry, merged ahead
404    /// of the caller's preamble.
405    pub default_instructions: Option<&'static str>,
406    /// The environment variable overriding [`Self::default_instructions`].
407    pub instructions_env: Option<&'static str>,
408    /// The caller identity this gateway requires on every request.
409    pub identity: Option<Identity>,
410    /// The header every completion request carries a session id in: the
411    /// request's cache key when it has one, else a fresh id.
412    pub session_header: Option<&'static str>,
413    /// What the Responses endpoint accepts.
414    pub responses: ResponsesQuirks,
415    /// Whether the catalog's per-token API prices apply to replies. A
416    /// subscription backend or a server that is usually local has none.
417    pub catalog_priced: bool,
418}
419
420impl Quirks {
421    /// Baseline compatible endpoint policies with Chat Completions routing and
422    /// the legacy `max_tokens` cap. Dialects override supported differences.
423    pub const fn openai() -> Self {
424        Self {
425            hooks: None,
426            auth: Auth::Bearer,
427            routing: Routing::Path,
428            completion_route: Route::Chat,
429            routes_by_catalog: false,
430            completion_path: "/chat/completions",
431            embeddings_path: "/embeddings",
432            models_path: "/models",
433            verify_path: "/models",
434            transcription_path: "/audio/transcriptions",
435            image_generation_path: "/images/generations",
436            audio_generation_path: "/audio/speech",
437            supports_tools: true,
438            strict_tools_prefix: None,
439            strict_schema: StrictSchema::OpenAi,
440            supports_response_format: true,
441            response_format_with_tools: false,
442            supports_image_tool_results: false,
443            later_system: crate::completion::LaterSystem::InPlace,
444            stream_include_usage: true,
445            done_without_finish_reason: false,
446            output_cap: OutputCap::Legacy,
447            native_finish_reason: false,
448            finishes: &[],
449            reasoning_field: None,
450            reliable_reasoning_count: true,
451            accepts_bare_string_reply: false,
452            accepts_file_ids: true,
453            short_lived_key: false,
454            rewrite: BodyRewrite::None,
455            embedding: EmbeddingQuirks::openai(),
456            // OpenAI has no reranking endpoint, and neither does any dialect
457            // on this wire but llama.cpp.
458            rerank: RerankQuirks::unsupported(),
459            root_relative_routes: &[],
460            model_is_modality_path: false,
461            image_body: ImageBody::OpenAi,
462            speech_body: SpeechBody::OpenAi,
463            transcription_body: TranscriptionBody::Multipart,
464            base_url_env_alias: None,
465            account_id_env: None,
466            default_instructions: None,
467            instructions_env: None,
468            identity: None,
469            session_header: None,
470            responses: ResponsesQuirks::openai(),
471            catalog_priced: true,
472        }
473    }
474
475    /// These quirks without the streamed usage chunk: a streaming request
476    /// sends no `stream_options`, so streamed usage reports `None`.
477    pub const fn without_stream_usage(mut self) -> Self {
478        self.stream_include_usage = false;
479        self
480    }
481
482    /// These quirks for a gateway whose streams omit `finish_reason`: a
483    /// `[DONE]` after at least one chunk ends the turn as
484    /// [`FinishReason::ToolCalls`](crate::completion::FinishReason::ToolCalls)
485    /// when the reply holds a tool call, and
486    /// [`FinishReason::Stop`](crate::completion::FinishReason::Stop)
487    /// otherwise. A cut-off stream that still sends `[DONE]` then reads as
488    /// complete, so set this only for a gateway known to omit the field.
489    pub const fn done_without_finish_reason(mut self) -> Self {
490        self.done_without_finish_reason = true;
491        self
492    }
493
494    /// These quirks without structured output: `output_schema` does not map
495    /// to `response_format`.
496    pub const fn without_response_format(mut self) -> Self {
497        self.supports_response_format = false;
498        self
499    }
500}
501
502impl Default for Quirks {
503    /// [`Quirks::openai`].
504    fn default() -> Self {
505        Self::openai()
506    }
507}
508
509/// Provider identity, endpoint defaults, and shared-wire policies.
510#[derive(Clone, Copy, Debug, PartialEq, Eq)]
511pub struct Dialect {
512    /// The provider descriptor name, as records and telemetry name it.
513    pub name: &'static str,
514    /// The default base URL.
515    pub base_url: &'static str,
516    /// The environment variable holding the credential.
517    pub api_key_env: &'static str,
518    /// The environment variable overriding the base URL, when the provider
519    /// has one.
520    pub base_url_env: Option<&'static str>,
521    /// The reply header carrying the provider's transport request id.
522    pub request_id_header: Option<&'static str>,
523    /// A second credential this dialect accepts, with its own variable and
524    /// header. `None` for every dialect but Azure.
525    pub alternate_auth: Option<AuthAlternative>,
526    /// Everything that is not identity.
527    pub quirks: Quirks,
528}
529
530impl Dialect {
531    /// Create a dialect with [`Quirks::openai`] and the supplied identity.
532    /// URL overrides, request-ID headers, and alternative credentials are unset.
533    pub const fn gateway(
534        name: &'static str,
535        base_url: &'static str,
536        api_key_env: &'static str,
537    ) -> Self {
538        Self {
539            name,
540            base_url,
541            api_key_env,
542            base_url_env: None,
543            request_id_header: None,
544            alternate_auth: None,
545            quirks: Quirks::openai(),
546        }
547    }
548
549    /// This dialect with `quirks`.
550    pub const fn with_quirks(mut self, quirks: Quirks) -> Self {
551        self.quirks = quirks;
552        self
553    }
554}
555
556/// Serialize the registered dialect name, rejecting unregistered or modified definitions.
557/// Deserialization resolves that name from this build's registry.
558impl Serialize for Dialect {
559    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
560        let registered = dialects::by_name(self.name) == Some(self);
561        crate::providers::internal::named_dialect::serialize(
562            serializer, "OpenAI", self.name, registered,
563        )
564    }
565}
566
567impl<'de> Deserialize<'de> for Dialect {
568    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
569        crate::providers::internal::named_dialect::deserialize(deserializer, "OpenAI", |name| {
570            dialects::by_name(name).copied()
571        })
572    }
573}
574
575/// The settings of an OpenAI-shaped provider: serializable, and the
576/// credential is never serialized. [`connect`](Self::connect) puts it on a
577/// transport as an [`OpenAI`](super::OpenAI) client.
578#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
579#[serde(deny_unknown_fields)]
580pub struct OpenAIConfig {
581    /// The credential. Never serialized (see [`Secret`]).
582    pub api_key: Secret,
583    /// The base URL every path resolves against.
584    pub base_url: String,
585    /// Which OpenAI-shaped provider this is.
586    pub dialect: Dialect,
587    /// The completion endpoint this configuration uses when asked for "a
588    /// completion", when it differs from the dialect's flagship
589    /// ([`Quirks::completion_route`]). Set by [`with_route`](Self::with_route).
590    #[serde(default, skip_serializing_if = "Option::is_none")]
591    pub route: Option<Route>,
592    /// Azure's `api-version` query parameter, which every Azure route
593    /// requires. `None` for every other dialect.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub api_version: Option<String>,
596    /// Azure versions its speech endpoint separately from the rest, so a
597    /// speech request carries this `api-version` instead of
598    /// [`Self::api_version`]. `None` falls back to `api_version`.
599    #[serde(default, skip_serializing_if = "Option::is_none")]
600    pub audio_api_version: Option<String>,
601    /// How this configuration's credential is sent. Taken from the dialect,
602    /// except when the credential came from the dialect's
603    /// [`alternate_auth`](Dialect::alternate_auth) variable, which has its
604    /// own header.
605    pub auth: Auth,
606    /// Which sub-provider the Hugging Face router forwards to. `None` behaves
607    /// as [`SubRoute::HFInference`], the router's own default. `None` for
608    /// every other dialect, which routes nothing.
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub sub_route: Option<SubRoute>,
611    /// The account the credential belongs to, when the gateway asks which
612    /// (`ChatGPT-Account-Id`).
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub account_id: Option<String>,
615    /// Instructions merged ahead of every Responses turn's preamble, when
616    /// the gateway expects some.
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub instructions: Option<String>,
619    /// The caller identity, when the gateway requires one.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub identity: Option<CallerIdentity>,
622    /// Responses instruction placement override. `None` uses the dialect default.
623    #[serde(default, skip_serializing_if = "Option::is_none")]
624    pub system_instructions: Option<SystemInstructionsPlacement>,
625}
626
627impl OpenAIConfig {
628    /// Official OpenAI, with `api_key`.
629    pub fn new(api_key: impl Into<Secret>) -> Self {
630        Self::with_key(&OPENAI, api_key)
631    }
632
633    /// `dialect` with `api_key`, at the dialect's default base URL and with
634    /// the instructions and caller identity its gateway expects, if any.
635    pub fn with_key(dialect: &Dialect, api_key: impl Into<Secret>) -> Self {
636        let quirks = &dialect.quirks;
637        let api_key = api_key.into();
638        let base_url = quirks
639            .hooks
640            .and_then(|hooks| hooks.default_endpoint)
641            .and_then(|endpoint| endpoint(api_key.expose()))
642            .unwrap_or_else(|| dialect.base_url.to_owned());
643        Self {
644            api_key,
645            base_url,
646            dialect: *dialect,
647            route: None,
648            // Azure deployment URLs require an explicit API version.
649            api_version: match quirks.routing {
650                Routing::AzureDeployment => Some(dialects::AZURE_DEFAULT_API_VERSION.to_owned()),
651                Routing::Path => None,
652            },
653            audio_api_version: None,
654            auth: quirks.auth,
655            sub_route: None,
656            account_id: None,
657            instructions: quirks.default_instructions.map(str::to_owned),
658            identity: quirks.identity.map(|identity| CallerIdentity {
659                originator: identity.originator.to_owned(),
660                user_agent: default_user_agent(identity.originator),
661            }),
662            system_instructions: None,
663        }
664    }
665
666    /// Read `OPENAI_API_KEY` and the optional `OPENAI_BASE_URL` override.
667    /// Return an environment error for missing credentials or invalid values.
668    pub fn from_env() -> Result<Self, EnvError> {
669        Self::from_env_with(&OPENAI)
670    }
671
672    /// `dialect` from its own `api_key_env` and `base_url_env` (or the
673    /// alias its quirks name), plus whatever else its gateway reads: the
674    /// account id, the default instructions and the caller identity.
675    ///
676    /// Azure additionally reads `AZURE_API_VERSION`, because every Azure
677    /// route carries it and there is no default that would not silently
678    /// address the wrong API.
679    pub fn from_env_with(dialect: &Dialect) -> Result<Self, EnvError> {
680        let (api_key, auth) = Self::credential_from_env(dialect)?;
681        Self::from_env_with_credential(dialect, api_key, auth)
682    }
683
684    /// [`Self::from_env_with`] with the credential already read.
685    pub(crate) fn from_env_with_credential(
686        dialect: &Dialect,
687        api_key: String,
688        auth: Auth,
689    ) -> Result<Self, EnvError> {
690        let quirks = &dialect.quirks;
691        let mut provider = Self::with_key(dialect, api_key);
692        provider.auth = auth;
693        for name in [dialect.base_url_env, quirks.base_url_env_alias]
694            .into_iter()
695            .flatten()
696        {
697            if let Some(base_url) = env::optional(name)? {
698                provider.base_url = base_url;
699                break;
700            }
701        }
702        // Azure speech uses an independently versioned endpoint.
703        if let Routing::AzureDeployment = quirks.routing {
704            provider.api_version = Some(env::required(dialects::AZURE_API_VERSION_ENV)?);
705            provider.audio_api_version = env::optional(dialects::AZURE_AUDIO_API_VERSION_ENV)?
706                .or_else(|| Some(dialects::AZURE_DEFAULT_AUDIO_API_VERSION.to_owned()));
707        }
708        if let Some(name) = quirks.account_id_env {
709            provider.account_id = env::optional(name)?;
710        }
711        if let Some(name) = quirks.instructions_env
712            && let Some(instructions) = env::optional(name)?
713            && !instructions.trim().is_empty()
714        {
715            provider.instructions = Some(instructions);
716        }
717        if let (Some(identity), Some(resolved)) = (quirks.identity, provider.identity.as_mut()) {
718            if let Some(originator) =
719                env::optional(identity.originator_env)?.filter(|value| !value.is_empty())
720            {
721                resolved.originator = originator;
722                resolved.user_agent = default_user_agent(&resolved.originator);
723            }
724            if let Some(user_agent) =
725                env::optional(identity.user_agent_env)?.filter(|value| !value.is_empty())
726            {
727                resolved.user_agent = user_agent;
728            }
729        }
730        Ok(provider)
731    }
732
733    /// Point this configuration at another dialect: the same credential,
734    /// with everything else at that dialect's defaults.
735    pub fn with_dialect(self, dialect: &Dialect) -> Self {
736        Self::with_key(dialect, self.api_key)
737    }
738
739    /// Route through a Hugging Face sub-provider.
740    pub fn with_sub_route(mut self, sub_route: SubRoute) -> Self {
741        self.sub_route = Some(sub_route);
742        self
743    }
744
745    /// Override the base URL.
746    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
747        self.base_url = base_url.into();
748        self
749    }
750
751    /// Set Azure's `api-version`.
752    pub fn with_api_version(mut self, api_version: impl Into<String>) -> Self {
753        self.api_version = Some(api_version.into());
754        self
755    }
756
757    /// Merge these instructions ahead of every Responses turn's preamble.
758    pub fn with_instructions(mut self, instructions: impl Into<String>) -> Self {
759        self.instructions = Some(instructions.into());
760        self
761    }
762
763    /// Put Rig's system instructions somewhere other than the dialect's
764    /// default placement, for every Responses wire this configuration
765    /// builds.
766    pub fn with_system_instructions_placement(
767        mut self,
768        placement: SystemInstructionsPlacement,
769    ) -> Self {
770        self.system_instructions = Some(placement);
771        self
772    }
773
774    /// Send Rig's system instructions as `system` messages in `input`, for a
775    /// backend that rejects or ignores top-level `instructions`.
776    pub fn with_system_instructions_as_messages(self) -> Self {
777        self.with_system_instructions_placement(SystemInstructionsPlacement::InputSystemMessages)
778    }
779
780    /// Where a Responses wire built from this configuration puts Rig's
781    /// system instructions: the dialect's placement unless
782    /// [`with_system_instructions_placement`](Self::with_system_instructions_placement)
783    /// chose another.
784    pub fn system_instructions_placement(&self) -> SystemInstructionsPlacement {
785        self.system_instructions
786            .unwrap_or(self.dialect.quirks.responses.system_instructions)
787    }
788
789    /// Override dialect and model-specific routing for the client's
790    /// [`completion`](crate::providers::openai::OpenAI::completion).
791    pub fn with_route(mut self, route: Route) -> Self {
792        self.route = Some(route);
793        self
794    }
795
796    /// The configured route or dialect's static default. A model-route hook
797    /// may refine the default when a completion wire is constructed.
798    pub fn completion_route(&self) -> Route {
799        self.route.unwrap_or(self.dialect.quirks.completion_route)
800    }
801
802    /// The completion wire for `model` on this configuration's
803    /// [`completion_route`](Self::completion_route): Responses for OpenAI,
804    /// xAI and ChatGPT, model-dependent routing when a dialect supplies it, and
805    /// Chat Completions for other compatible gateways, unless
806    /// [`with_route`](Self::with_route) chose the other one.
807    pub(crate) fn completion(&self, model: impl Into<String>) -> OpenAiWire {
808        OpenAiWire::new(self.clone(), model)
809    }
810
811    /// The Responses wire for `model`: `POST /responses`.
812    pub(crate) fn responses(&self, model: impl Into<String>) -> Responses {
813        Responses::new(self.clone(), model)
814    }
815
816    /// The chat-completions wire for `model`, whatever the dialect's
817    /// [`completion_route`](Quirks::completion_route).
818    pub fn chat(&self, model: impl Into<String>) -> Chat {
819        Chat::new(self.clone(), model)
820    }
821
822    pub(crate) fn completion_headers(
823        &self,
824        request: &crate::completion::CompletionRequest,
825        builder: http::request::Builder,
826    ) -> http::request::Builder {
827        let builder = self.headers_in_session(builder, request.options.cache_key.as_deref());
828        match self
829            .dialect
830            .quirks
831            .hooks
832            .and_then(|hooks| hooks.completion_envelope)
833        {
834            Some(envelope) => envelope(self, request, builder),
835            None => builder,
836        }
837    }
838
839    /// Resolve `path` against the base URL, applying Azure's
840    /// deployment-in-URL routing when the dialect uses it.
841    pub(crate) fn uri(&self, path: &str, model: Option<&str>) -> String {
842        self.uri_versioned(path, model, self.api_version.as_deref())
843    }
844
845    /// [`Self::uri`] with an explicit `api-version`, for the one endpoint
846    /// Azure versions separately (speech).
847    pub(crate) fn uri_versioned(
848        &self,
849        path: &str,
850        model: Option<&str>,
851        api_version: Option<&str>,
852    ) -> String {
853        match (self.dialect.quirks.routing, model) {
854            (Routing::AzureDeployment, Some(model)) => format!(
855                "{}/openai/deployments/{}{}?api-version={}",
856                self.base_url.trim_end_matches('/'),
857                model.trim_start_matches('/'),
858                path,
859                api_version.unwrap_or_default(),
860            ),
861            _ => format!("{}{}", self.base(path), path),
862        }
863    }
864
865    /// The base URL `path` resolves against: the configured one, with the
866    /// version segment dropped for a route the dialect serves at the root.
867    fn base(&self, path: &str) -> &str {
868        let base = self.base_url.trim_end_matches('/');
869        if self.dialect.quirks.root_relative_routes.contains(&path) {
870            return base.strip_suffix("/v1").unwrap_or(base);
871        }
872        base
873    }
874
875    /// The sub-provider the Hugging Face router forwards to. `None` on the
876    /// configuration means the router's own default.
877    pub(crate) fn route(&self) -> std::borrow::Cow<'_, SubRoute> {
878        match &self.sub_route {
879            Some(route) => std::borrow::Cow::Borrowed(route),
880            None => std::borrow::Cow::Owned(SubRoute::default()),
881        }
882    }
883
884    /// Return `model` for Azure deployment routing, otherwise `None`.
885    pub(crate) fn deployment<'a>(&self, model: &'a str) -> Option<&'a str> {
886        match self.dialect.quirks.routing {
887            Routing::AzureDeployment => Some(model),
888            Routing::Path => None,
889        }
890    }
891}
892
893#[cfg(test)]
894mod tests;