1use async_trait::async_trait;
19use serde::Serialize;
20use serde_json::Value;
21use turul_llm_core::{
22 ChatResponseShape, CompletionRequest, CompletionResponse, LlmClient, LlmError, http::send_chat,
23};
24
25const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
26const DEFAULT_SCHEMA_NAME: &str = "structured_output";
27
28#[derive(Debug, Clone)]
30pub struct OpenAiClient {
31 base_url: String,
32 api_key: String,
33 model: String,
34 http: reqwest::Client,
35 schema_name: String,
36}
37
38impl OpenAiClient {
39 pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
41 Self::with_base_url(DEFAULT_BASE_URL, api_key, model)
42 }
43
44 pub fn with_base_url(
48 base_url: impl Into<String>,
49 api_key: impl Into<String>,
50 model: impl Into<String>,
51 ) -> Self {
52 Self::with_http_client(base_url, api_key, model, reqwest::Client::new())
53 }
54
55 pub fn with_http_client(
57 base_url: impl Into<String>,
58 api_key: impl Into<String>,
59 model: impl Into<String>,
60 http: reqwest::Client,
61 ) -> Self {
62 let base = base_url.into();
63 Self {
64 base_url: base.trim_end_matches('/').to_string(),
65 api_key: api_key.into(),
66 model: model.into(),
67 http,
68 schema_name: DEFAULT_SCHEMA_NAME.to_string(),
69 }
70 }
71
72 pub fn with_schema_name(mut self, name: impl Into<String>) -> Self {
76 self.schema_name = name.into();
77 self
78 }
79
80 pub fn model(&self) -> &str {
82 &self.model
83 }
84
85 pub fn base_url(&self) -> &str {
87 &self.base_url
88 }
89
90 fn endpoint(&self) -> String {
91 format!("{}/chat/completions", self.base_url)
92 }
93}
94
95#[derive(Serialize)]
96struct ChatRequest<'a> {
97 model: &'a str,
98 messages: Vec<ChatMessage<'a>>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 response_format: Option<ResponseFormat<'a>>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 max_tokens: Option<u32>,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 temperature: Option<f32>,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 top_p: Option<f32>,
107}
108
109#[derive(Serialize)]
110struct ChatMessage<'a> {
111 role: &'a str,
112 content: &'a str,
113}
114
115#[derive(Serialize)]
116#[serde(tag = "type", rename_all = "snake_case")]
117enum ResponseFormat<'a> {
118 JsonSchema { json_schema: JsonSchemaBlock<'a> },
119}
120
121#[derive(Serialize)]
122struct JsonSchemaBlock<'a> {
123 name: &'a str,
124 schema: &'a Value,
125 strict: bool,
126}
127
128fn apply_hints(req: &mut ChatRequest<'_>, request: &CompletionRequest) {
129 req.max_tokens = request.max_output_tokens();
130 req.temperature = request.execution_hints.temperature;
131 req.top_p = request.execution_hints.top_p;
132}
133
134#[async_trait]
135impl LlmClient for OpenAiClient {
136 async fn complete(&self, request: CompletionRequest) -> Result<CompletionResponse, LlmError> {
137 let response_format =
138 request
139 .output_schema
140 .as_ref()
141 .map(|schema| ResponseFormat::JsonSchema {
142 json_schema: JsonSchemaBlock {
143 name: &self.schema_name,
144 schema,
145 strict: true,
146 },
147 });
148
149 let mut body = ChatRequest {
150 model: &self.model,
151 messages: vec![ChatMessage {
152 role: "user",
153 content: &request.rendered_prompt,
154 }],
155 response_format,
156 max_tokens: None,
157 temperature: None,
158 top_p: None,
159 };
160 apply_hints(&mut body, &request);
161
162 let url = self.endpoint();
163 send_chat(
164 self.http.post(&url).bearer_auth(&self.api_key).json(&body),
165 &url,
166 &ChatResponseShape::new("/choices/0/message/content", "/choices/0/finish_reason")
167 .with_usage_pointers("/usage/prompt_tokens", "/usage/completion_tokens"),
168 request.output_schema.is_some(),
169 )
170 .await
171 }
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use serde_json::json;
178 use turul_llm_core::ExecutionHints;
179 use wiremock::matchers::{body_partial_json, header, method, path};
180 use wiremock::{Mock, MockServer, ResponseTemplate};
181
182 fn chat_envelope(content: &str) -> ResponseTemplate {
183 ResponseTemplate::new(200).set_body_json(json!({
184 "id": "chatcmpl-test",
185 "object": "chat.completion",
186 "choices": [{
187 "index": 0,
188 "message": {"role": "assistant", "content": content},
189 "finish_reason": "stop"
190 }]
191 }))
192 }
193
194 #[tokio::test(flavor = "current_thread")]
195 async fn structured_call_emits_response_format_json_schema() {
196 let server = MockServer::start().await;
197 let schema = json!({
198 "type": "object",
199 "properties": {"greeting": {"type": "string"}},
200 "required": ["greeting"]
201 });
202 let expected_payload = json!({"greeting": "Hi, Ada!"});
203 let content = serde_json::to_string(&expected_payload).unwrap();
204
205 Mock::given(method("POST"))
206 .and(path("/chat/completions"))
207 .and(header("authorization", "Bearer test-key"))
208 .and(body_partial_json(json!({
209 "model": "gpt-4o-mini",
210 "messages": [{"role": "user", "content": "Greet Ada warmly."}],
211 "response_format": {
212 "type": "json_schema",
213 "json_schema": {
214 "name": "structured_output",
215 "schema": schema.clone(),
216 "strict": true
217 }
218 },
219 "max_tokens": 64,
220 "temperature": 0.2
221 })))
222 .respond_with(chat_envelope(&content))
223 .mount(&server)
224 .await;
225
226 let client = OpenAiClient::with_base_url(server.uri(), "test-key", "gpt-4o-mini");
227 let request = CompletionRequest::new("Greet Ada warmly.")
228 .with_output_schema(schema)
229 .with_execution_hints(
230 ExecutionHints::new()
231 .with_max_tokens(64)
232 .with_temperature(0.2),
233 );
234 let response = client.complete(request).await.expect("ok");
235 assert_eq!(response.parsed_output, expected_payload);
236 }
237
238 #[tokio::test(flavor = "current_thread")]
239 async fn freeform_response_is_wrapped_as_json_string() {
240 let server = MockServer::start().await;
241 Mock::given(method("POST"))
242 .and(path("/chat/completions"))
243 .respond_with(chat_envelope("Hello there."))
244 .mount(&server)
245 .await;
246
247 let client = OpenAiClient::with_base_url(server.uri(), "k", "m");
248 let response = client
249 .complete(CompletionRequest::new("hi"))
250 .await
251 .expect("ok");
252 assert_eq!(response.parsed_output, Value::String("Hello there.".into()));
253 }
254
255 #[tokio::test(flavor = "current_thread")]
256 async fn http_error_maps_to_provider_variant() {
257 let server = MockServer::start().await;
258 Mock::given(method("POST"))
259 .and(path("/chat/completions"))
260 .respond_with(ResponseTemplate::new(429).set_body_string("rate limited"))
261 .mount(&server)
262 .await;
263
264 let client = OpenAiClient::with_base_url(server.uri(), "k", "m");
265 let err = client
266 .complete(CompletionRequest::new("hi"))
267 .await
268 .expect_err("must fail");
269 assert!(matches!(err, LlmError::Provider(_)), "got {err:?}");
270 }
271
272 #[tokio::test(flavor = "current_thread")]
273 async fn invalid_structured_payload_maps_to_schema_violation() {
274 let server = MockServer::start().await;
275 Mock::given(method("POST"))
276 .and(path("/chat/completions"))
277 .respond_with(chat_envelope("not json"))
278 .mount(&server)
279 .await;
280
281 let client = OpenAiClient::with_base_url(server.uri(), "k", "m");
282 let err = client
283 .complete(CompletionRequest::new("hi").with_output_schema(json!({"type": "object"})))
284 .await
285 .expect_err("must fail");
286 assert!(matches!(err, LlmError::SchemaViolation(_)), "got {err:?}");
287 }
288
289 #[tokio::test(flavor = "current_thread")]
290 async fn budget_output_cap_overrides_the_max_tokens_hint() {
291 let server = MockServer::start().await;
292 Mock::given(method("POST"))
293 .and(path("/chat/completions"))
294 .and(body_partial_json(json!({"max_tokens": 512})))
295 .respond_with(chat_envelope("ok"))
296 .expect(1)
297 .mount(&server)
298 .await;
299
300 let client = OpenAiClient::with_base_url(server.uri(), "k", "m");
301 let request = CompletionRequest::new("hi")
302 .with_execution_hints(ExecutionHints::new().with_max_tokens(7))
303 .with_budget(turul_llm_core::ContextBudget::new(
304 std::num::NonZeroU32::new(8192).unwrap(),
305 std::num::NonZeroU32::new(512).unwrap(),
306 ));
307 client.complete(request).await.expect("ok");
308 }
309
310 #[tokio::test(flavor = "current_thread")]
311 async fn usage_and_stop_reason_are_decoded() {
312 let server = MockServer::start().await;
313 Mock::given(method("POST"))
314 .and(path("/chat/completions"))
315 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
316 "choices": [{
317 "index": 0,
318 "message": {"role": "assistant", "content": "ok"},
319 "finish_reason": "length"
320 }],
321 "usage": {"prompt_tokens": 9, "completion_tokens": 5, "total_tokens": 14}
322 })))
323 .mount(&server)
324 .await;
325
326 let client = OpenAiClient::with_base_url(server.uri(), "k", "m");
327 let out = client
328 .complete(CompletionRequest::new("hi"))
329 .await
330 .expect("ok");
331 assert_eq!(out.stop_reason, Some(turul_llm_core::StopReason::Length));
332 assert_eq!(
333 out.usage,
334 Some(turul_llm_core::Usage::new(Some(9), Some(5)))
335 );
336 }
337
338 #[tokio::test(flavor = "current_thread")]
339 async fn truncated_structured_payload_reports_finish_reason() {
340 let server = MockServer::start().await;
341 Mock::given(method("POST"))
342 .and(path("/chat/completions"))
343 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
344 "choices": [{
345 "index": 0,
346 "message": {"role": "assistant", "content": "{\"a\": \"cut"},
347 "finish_reason": "length"
348 }]
349 })))
350 .mount(&server)
351 .await;
352
353 let client = OpenAiClient::with_base_url(server.uri(), "k", "m");
354 let err = client
355 .complete(CompletionRequest::new("hi").with_output_schema(json!({"type": "object"})))
356 .await
357 .expect_err("must fail");
358 match err {
359 LlmError::SchemaViolation(m) => assert!(m.contains("finish_reason=length"), "{m}"),
360 other => panic!("got {other:?}"),
361 }
362 }
363}