Skip to main content

rig_core/providers/openai/wire/
route.rs

1//! Dispatches completion requests to Chat Completions or Responses wires.
2//! Explicit configuration routes override model-specific hooks, which
3//! override the model's catalog row where the dialect routes by it, which
4//! overrides the dialect default.
5//!
6//! ```
7//! use rig_core::providers::openai::{OpenAI, OpenAIConfig, Route};
8//! let provider: OpenAI = OpenAIConfig::new("key").with_route(Route::Chat).client();
9//! let model = provider.completion("gpt-5.2");
10//! ```
11
12use serde::{Deserialize, Serialize};
13
14use crate::catalog::ModelFacts;
15use crate::completion::CompletionRequest;
16use crate::error::{EncodeError, ProviderError};
17use crate::operation::Completion;
18use crate::providers::openai::responses_api::streaming::{ResponsesDecoder, ResponsesEvent};
19use crate::providers::openai::responses_api::wire::Responses;
20use crate::providers::openai::responses_api::{
21    ResponsesToolDefinition, SystemInstructionsPlacement,
22};
23use crate::wire::document::Reassemble;
24use crate::wire::{Decoder, Descriptor, Encoded, Flow, Mode, Out, Wire, WireEvent, WireFrame};
25
26use super::OpenAIConfig;
27use super::chat::{Chat, ChatDecoder, ChatEvent};
28
29/// Dispatch a shared expression to the selected route.
30macro_rules! on_route {
31    ($chosen:expr, $wire:ident => $ask:expr) => {
32        match $chosen {
33            Self::Chat($wire) => $ask,
34            Self::Responses($wire) => $ask,
35        }
36    };
37}
38
39/// Which completion endpoint a configuration serves.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41pub enum Route {
42    /// `POST /chat/completions`: the one endpoint every dialect serves.
43    Chat,
44    /// `POST /responses`: the flagship of OpenAI, xAI and ChatGPT.
45    Responses,
46}
47
48/// The API a catalog row says serves its model, as models.dev names it: the
49/// AI SDK package, or the API shape.
50fn catalog_route(spec: &crate::catalog::ModelSpec) -> Option<Route> {
51    let provider = spec.details.provider.as_ref()?;
52    match (provider.npm.as_deref(), provider.shape.as_deref()) {
53        (_, Some("responses")) | (Some("@ai-sdk/openai"), None) => Some(Route::Responses),
54        (_, Some("completions")) | (Some("@ai-sdk/openai-compatible"), None) => Some(Route::Chat),
55        _ => None,
56    }
57}
58
59/// A completion wire selected by configuration, model hook, catalog row, or
60/// dialect default.
61/// Route-specific options are no-ops on the other route.
62#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
63pub enum OpenAiWire {
64    /// The chat-completions wire.
65    Chat(Chat),
66    /// The Responses wire.
67    Responses(Responses),
68}
69
70impl OpenAiWire {
71    /// The wire for `model` on `provider`: the route
72    /// [`with_route`](OpenAIConfig::with_route) configured, else the
73    /// dialect's model hook's, else, for a dialect that
74    /// [routes by catalog](super::Quirks::routes_by_catalog), the API the
75    /// built-in catalog lists for the model, else the dialect's
76    /// [`completion_route`](OpenAIConfig::completion_route).
77    pub fn new(provider: OpenAIConfig, model: impl Into<String>) -> Self {
78        Self::routed(provider, model, ModelFacts::builtin())
79    }
80
81    /// [`Self::new`], reading the model's API from `facts`.
82    pub(crate) fn routed(
83        provider: OpenAIConfig,
84        model: impl Into<String>,
85        facts: &ModelFacts,
86    ) -> Self {
87        let model = model.into();
88        let hook = provider
89            .dialect
90            .quirks
91            .hooks
92            .and_then(|hooks| hooks.model_route);
93        let route = provider
94            .route
95            .or_else(|| hook.map(|route| route(&model)))
96            .or_else(|| match provider.dialect.quirks.routes_by_catalog {
97                true => catalog_route(facts.for_model(provider.dialect.name, &model)?),
98                false => None,
99            })
100            .unwrap_or_else(|| provider.completion_route());
101        match route {
102            Route::Chat => Self::Chat(Chat::new(provider, model)),
103            Route::Responses => Self::Responses(Responses::new(provider, model)),
104        }
105    }
106
107    /// The configuration this wire speaks to.
108    pub fn provider(&self) -> &OpenAIConfig {
109        on_route!(self, wire => &wire.provider)
110    }
111
112    /// Sanitize tool schemas for OpenAI's strict mode on whichever route
113    /// this is.
114    pub fn with_strict_tools(self) -> Self {
115        self.on_chat(Chat::with_strict_tools)
116            .on_responses(Responses::with_strict_tools)
117    }
118
119    /// Serialize tool-result content as arrays: a chat-completions shape,
120    /// so a no-op on the Responses route, whose request has one content
121    /// encoding.
122    pub fn with_tool_result_array_content(self) -> Self {
123        self.on_chat(Chat::with_tool_result_array_content)
124    }
125
126    /// Add a provider-side tool to every request: a Responses shape, so a
127    /// no-op on the chat route, which carries no wire-level tools.
128    pub fn with_tool(self, tool: impl Into<ResponsesToolDefinition>) -> Self {
129        self.on_responses(|wire| wire.with_tool(tool))
130    }
131
132    /// Add provider-side tools to every request: a no-op on the chat
133    /// route, which carries no wire-level tools.
134    pub fn with_tools<I, Tool>(self, tools: I) -> Self
135    where
136        I: IntoIterator<Item = Tool>,
137        Tool: Into<ResponsesToolDefinition>,
138    {
139        self.on_responses(|wire| wire.with_tools(tools))
140    }
141
142    /// Put Rig's system instructions somewhere other than the dialect's
143    /// default placement: a no-op on the chat route, where a system message
144    /// has one place to go.
145    pub fn with_system_instructions_placement(
146        self,
147        placement: SystemInstructionsPlacement,
148    ) -> Self {
149        self.on_responses(|wire| wire.with_system_instructions_placement(placement))
150    }
151
152    /// Send Rig's system instructions as `system` messages in `input`: a
153    /// no-op on the chat route, which sends them that way already.
154    pub fn with_system_instructions_as_messages(self) -> Self {
155        self.on_responses(Responses::with_system_instructions_as_messages)
156    }
157
158    /// The same wire, encoding with `facts` and pricing its replies by
159    /// them, on whichever route this is.
160    pub fn with_facts(self, facts: ModelFacts) -> Self {
161        match self {
162            Self::Chat(wire) => Self::Chat(wire.with_facts(facts)),
163            Self::Responses(wire) => Self::Responses(wire.with_facts(facts)),
164        }
165    }
166
167    /// Apply a chat-route option; the Responses route is left as it is.
168    fn on_chat(self, option: impl FnOnce(Chat) -> Chat) -> Self {
169        match self {
170            Self::Chat(wire) => Self::Chat(option(wire)),
171            responses => responses,
172        }
173    }
174
175    /// Apply a Responses-route option; the chat route is left as it is.
176    fn on_responses(self, option: impl FnOnce(Responses) -> Responses) -> Self {
177        match self {
178            Self::Responses(wire) => Self::Responses(option(wire)),
179            chat => chat,
180        }
181    }
182}
183
184impl From<Chat> for OpenAiWire {
185    fn from(wire: Chat) -> Self {
186        Self::Chat(wire)
187    }
188}
189
190impl From<Responses> for OpenAiWire {
191    fn from(wire: Responses) -> Self {
192        Self::Responses(wire)
193    }
194}
195
196impl Wire for OpenAiWire {
197    type Op = Completion;
198    type Payload = crate::wire::Encoded;
199    type Frame = crate::wire::WireFrame;
200    type Event = OpenAiEvent;
201    type Decoder<'id> = OpenAiDecoder;
202    type Reassembler = OpenAiReassembler;
203
204    fn describe(&self) -> Descriptor<'_> {
205        on_route!(self, wire => wire.describe())
206    }
207
208    fn encode(&self, request: CompletionRequest, mode: Mode) -> Result<Encoded, EncodeError> {
209        on_route!(self, wire => wire.encode(request, mode))
210    }
211
212    fn decoder<'id>(&self) -> Self::Decoder<'id> {
213        match self {
214            Self::Chat(wire) => OpenAiDecoder::Chat(wire.decoder()),
215            Self::Responses(wire) => OpenAiDecoder::Responses(wire.decoder()),
216        }
217    }
218
219    fn reassembler(&self) -> Self::Reassembler {
220        match self {
221            Self::Chat(wire) => OpenAiReassembler::Chat(wire.reassembler()),
222            Self::Responses(wire) => OpenAiReassembler::Responses(wire.reassembler()),
223        }
224    }
225}
226
227/// The chosen route's reassembler.
228pub enum OpenAiReassembler {
229    /// The chat-completions reply document.
230    Chat(<Chat as Wire>::Reassembler),
231    /// The Responses reply document.
232    Responses(<Responses as Wire>::Reassembler),
233}
234
235/// The chat route's, as [`OpenAiWire`]'s default route is.
236impl Default for OpenAiReassembler {
237    fn default() -> Self {
238        Self::Chat(Default::default())
239    }
240}
241
242impl crate::wire::document::Serves<crate::operation::Completion> for OpenAiReassembler {}
243
244impl Reassemble<OpenAiEvent> for OpenAiReassembler {
245    fn absorb(&mut self, event: &OpenAiEvent) {
246        match (self, event) {
247            (Self::Chat(document), OpenAiEvent::Chat(event)) => document.absorb(event),
248            (Self::Responses(document), OpenAiEvent::Responses(event)) => document.absorb(event),
249            // The route's decoder classifies only its own events.
250            _ => {}
251        }
252    }
253
254    fn finish(self) -> serde_json::Value {
255        on_route!(self, document => document.finish())
256    }
257}
258
259/// One classified frame of whichever route is answering.
260pub enum OpenAiEvent {
261    /// A chat-completions frame.
262    Chat(ChatEvent),
263    /// A Responses frame.
264    Responses(ResponsesEvent),
265}
266
267/// The chosen route's decoder.
268pub enum OpenAiDecoder {
269    /// The chat-completions state machine.
270    Chat(ChatDecoder),
271    /// The Responses state machine.
272    Responses(ResponsesDecoder),
273}
274
275impl<'id> Decoder<'id, Completion> for OpenAiDecoder {
276    type Event = OpenAiEvent;
277
278    fn classify(&self, frame: WireFrame) -> WireEvent<OpenAiEvent> {
279        match self {
280            Self::Chat(decoder) => decoder.classify(frame).map(OpenAiEvent::Chat),
281            Self::Responses(decoder) => decoder.classify(frame).map(OpenAiEvent::Responses),
282        }
283    }
284
285    fn decode(
286        &mut self,
287        event: OpenAiEvent,
288        out: Out<'id, Completion>,
289    ) -> Result<Flow, ProviderError> {
290        match (self, event) {
291            (Self::Chat(decoder), OpenAiEvent::Chat(event)) => decoder.decode(event, out),
292            (Self::Responses(decoder), OpenAiEvent::Responses(event)) => decoder.decode(event, out),
293            // The driver feeds each decoder only events from its own
294            // classifier.
295            (Self::Chat(_), OpenAiEvent::Responses(_))
296            | (Self::Responses(_), OpenAiEvent::Chat(_)) => Ok(Flow::More),
297        }
298    }
299
300    fn eof(&mut self, out: Out<'id, Completion>) -> Result<Flow, ProviderError> {
301        on_route!(self, decoder => decoder.eof(out))
302    }
303}
304
305#[cfg(test)]
306mod tests;