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