1use 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
22pub struct OpenAiDecisionClient {
24 client: reqwest::Client,
25 endpoint: Url,
26 api_key: String,
27}
28
29impl OpenAiDecisionClient {
30 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
248fn 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}