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;