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;