Skip to main content

switchyard_llm_client/
openai_decisions.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! OpenAI Decisions calls using the provider-neutral decision IR.
5
6use std::collections::BTreeMap;
7use std::time::Duration;
8
9use async_trait::async_trait;
10use reqwest::{Url, header::HeaderValue};
11use serde::Deserialize;
12use serde_json::{Value, json};
13use switchyard_protocol::{
14    BooleanEstimate, DecisionAnswer, DecisionKind, DecisionRequest, DecisionResponse,
15    DecisionValue, ModelId, Probability, ProviderConfidence, RoutedDecisionClient, ScoreValue,
16    Usage,
17};
18
19use crate::client::convert_reqwest_error;
20use crate::{LlmClientError, Result, metrics};
21
22/// Serves typed questions through OpenAI's Decisions API.
23pub struct OpenAiDecisionClient {
24    client: reqwest::Client,
25    endpoint: Url,
26    api_key: String,
27}
28
29impl OpenAiDecisionClient {
30    /// `endpoint` includes `/v1/decisions`. Each call makes one attempt, with
31    /// `timeout` covering both the request and response body.
32    pub fn new(endpoint: Url, api_key: String, timeout: Duration) -> Result<Self> {
33        if HeaderValue::try_from(format!("Bearer {api_key}")).is_err() {
34            return Err(LlmClientError::Configuration {
35                message: "OpenAI Decisions API key cannot be encoded as an HTTP header".into(),
36            });
37        }
38        let client = reqwest::Client::builder()
39            .timeout(timeout)
40            .redirect(reqwest::redirect::Policy::none())
41            .build()
42            .map_err(convert_reqwest_error)?;
43        Ok(Self {
44            client,
45            endpoint,
46            api_key,
47        })
48    }
49}
50
51#[async_trait]
52impl RoutedDecisionClient for OpenAiDecisionClient {
53    async fn call(&self, request: DecisionRequest) -> Result<DecisionResponse> {
54        let response = self
55            .client
56            .post(self.endpoint.clone())
57            .bearer_auth(&self.api_key)
58            .json(&encode(&request)?)
59            .send()
60            .await
61            .inspect_err(|_| metrics::record_upstream_attempt(None))
62            .map_err(convert_reqwest_error)?;
63        let status = response.status();
64        let body = response
65            .bytes()
66            .await
67            .inspect_err(|_| metrics::record_upstream_attempt(None))
68            .map_err(convert_reqwest_error)?;
69        metrics::record_upstream_attempt(Some(status.as_u16()));
70        if !status.is_success() {
71            return Err(LlmClientError::UpstreamHttp {
72                status,
73                body: String::from_utf8_lossy(&body).replace(&self.api_key, "[REDACTED]"),
74            });
75        }
76        let response: WireResponse =
77            serde_json::from_slice(&body).map_err(|source| LlmClientError::InvalidResponse {
78                source: Box::new(source),
79            })?;
80        let mut questions = request.questions;
81        let mut answers = BTreeMap::new();
82        for answer in response.answers {
83            let invalid = || {
84                LlmClientError::ResponseTranslation(format!(
85                    "OpenAI Decisions answer {:?} has an unexpected question name, kind, or value",
86                    answer.name,
87                ))
88            };
89            let question = questions.remove(&answer.name).ok_or_else(invalid)?;
90            let value = match (&question.kind, answer.value) {
91                (DecisionKind::Boolean { .. }, WireValue::Predicate { probability }) => {
92                    DecisionValue::Boolean(BooleanEstimate::ProbabilityTrue(probability))
93                }
94                (
95                    DecisionKind::Choice { options },
96                    WireValue::Choice {
97                        choice,
98                        probabilities,
99                    },
100                ) => {
101                    let count = probabilities.len();
102                    let probabilities: BTreeMap<_, _> = probabilities
103                        .into_iter()
104                        .map(|p| (p.value, p.probability))
105                        .collect();
106                    if count != options.len()
107                        || probabilities.len() != options.len()
108                        || !options.iter().any(|option| option.id == choice)
109                        || options
110                            .iter()
111                            .any(|option| !probabilities.contains_key(&option.id))
112                    {
113                        return Err(invalid());
114                    }
115                    DecisionValue::Choice {
116                        selected: choice,
117                        probabilities: Some(probabilities),
118                    }
119                }
120                (
121                    DecisionKind::Score { levels },
122                    WireValue::Score {
123                        score,
124                        probabilities,
125                    },
126                ) => {
127                    let mut probabilities = probabilities;
128                    probabilities.sort_by_key(|p| p.value);
129                    if probabilities.len() != levels.len()
130                        || probabilities
131                            .iter()
132                            .enumerate()
133                            .any(|(index, p)| p.value != index)
134                    {
135                        return Err(invalid());
136                    }
137                    DecisionValue::Score {
138                        value: score,
139                        probabilities: Some(
140                            probabilities.into_iter().map(|p| p.probability).collect(),
141                        ),
142                    }
143                }
144                (_, WireValue::Refusal) => {
145                    return Err(LlmClientError::ResponseTranslation(format!(
146                        "OpenAI Decisions refused question {:?}",
147                        answer.name,
148                    )));
149                }
150                _ => return Err(invalid()),
151            };
152            answers.insert(
153                answer.name,
154                DecisionAnswer {
155                    value,
156                    provider_confidence: answer.confidence,
157                },
158            );
159        }
160        if !questions.is_empty() {
161            return Err(LlmClientError::ResponseTranslation(
162                "OpenAI Decisions answer names do not match the requested question keys".into(),
163            ));
164        }
165        let usage = response.usage;
166        let cached = usage.input_tokens_details.cached_tokens;
167        let created = usage.input_tokens_details.cache_write_tokens;
168        Ok(DecisionResponse {
169            id: None,
170            model: Some(response.model),
171            answers,
172            usage: Usage {
173                input_tokens: Some(
174                    usage
175                        .input_tokens
176                        .saturating_sub(cached)
177                        .saturating_sub(created),
178                ),
179                cache: Usage::cache_details(Some(cached), Some(created)),
180                output_tokens: Some(usage.output_tokens),
181                total_tokens: Some(usage.total_tokens),
182                reasoning_tokens: Some(usage.output_tokens_details.reasoning_tokens),
183                ..Usage::default()
184            },
185        })
186    }
187}
188
189fn encode(request: &DecisionRequest) -> Result<Value> {
190    let model = request
191        .model
192        .as_ref()
193        .ok_or_else(|| LlmClientError::InvalidRequest {
194            message: "OpenAI Decisions requires a selected model".into(),
195        })?;
196    let questions: Vec<_> = request
197        .questions
198        .iter()
199        .map(|(name, question)| {
200            let mut wire = json!({"name": name, "instructions": text(&question.instructions)});
201            match &question.kind {
202                DecisionKind::Boolean {
203                    true_description,
204                    false_description,
205                } => {
206                    wire["type"] = json!("predicate");
207                    if true_description.is_some() || false_description.is_some() {
208                        wire["instructions"] = json!(
209                            json!({
210                                "instructions": question.instructions,
211                                "true": true_description, "false": false_description,
212                            })
213                            .to_string()
214                        );
215                    }
216                }
217                DecisionKind::Choice { options } => {
218                    wire["type"] = json!("choice");
219                    wire["choices"] = Value::Array(
220                        options
221                            .iter()
222                            .map(|option| {
223                                let mut choice = json!({"value": option.id});
224                                if let Some(description) = &option.description {
225                                    choice["description"] = json!(text(description));
226                                }
227                                choice
228                            })
229                            .collect(),
230                    );
231                }
232                DecisionKind::Score { levels } => {
233                    wire["type"] = json!("score");
234                    wire["levels"] = Value::Array(
235                        levels
236                            .iter()
237                            .map(|level| json!({"label": text(level)}))
238                            .collect(),
239                    );
240                }
241            }
242            wire
243        })
244        .collect();
245    Ok(json!({"model": model, "input": text(&request.context), "questions": questions}))
246}
247
248// OpenAI requires text; retain structured IR content as JSON rather than dropping it.
249fn text(value: &Value) -> String {
250    value
251        .as_str()
252        .map(str::to_owned)
253        .unwrap_or_else(|| value.to_string())
254}
255
256#[derive(Deserialize)]
257struct WireResponse {
258    model: ModelId,
259    answers: Vec<WireAnswer>,
260    usage: WireUsage,
261}
262
263#[derive(Deserialize)]
264struct WireAnswer {
265    name: String,
266    #[serde(flatten)]
267    value: WireValue,
268    confidence: Option<ProviderConfidence>,
269}
270
271#[derive(Deserialize)]
272#[serde(tag = "type", rename_all = "snake_case")]
273enum WireValue {
274    Predicate {
275        probability: Probability,
276    },
277    Choice {
278        choice: String,
279        probabilities: Vec<WireProbability<String>>,
280    },
281    Score {
282        score: ScoreValue,
283        probabilities: Vec<WireProbability<usize>>,
284    },
285    Refusal,
286}
287
288#[derive(Deserialize)]
289struct WireProbability<T> {
290    value: T,
291    probability: Probability,
292}
293
294#[derive(Deserialize)]
295struct WireUsage {
296    input_tokens: u64,
297    output_tokens: u64,
298    total_tokens: u64,
299    input_tokens_details: InputTokenDetails,
300    output_tokens_details: OutputTokenDetails,
301}
302
303#[derive(Deserialize)]
304struct InputTokenDetails {
305    cached_tokens: u64,
306    cache_write_tokens: u64,
307}
308
309#[derive(Deserialize)]
310struct OutputTokenDetails {
311    reasoning_tokens: u64,
312}