Skip to main content

turul_llm_openai/
lib.rs

1//! OpenAI-compatible adapter for [`turul_llm_core::LlmClient`].
2//!
3//! Targets the chat completions API at `{base_url}/chat/completions`
4//! with a Bearer-token `Authorization` header. The `base_url` is
5//! configurable so this adapter also works against OpenAI-compatible
6//! gateways (Together.ai, vLLM, Groq, etc.); the default points at
7//! `https://api.openai.com/v1`.
8//!
9//! Structured output uses `response_format = { type: "json_schema",
10//! json_schema: { name, schema, strict } }` — the official structured
11//! output path. The adapter sets `strict: true` so the model is held to
12//! the schema and surfaces a [`LlmError::SchemaViolation`] when the
13//! returned content still fails to parse as JSON.
14//!
15//! Tests are `wiremock`-backed; live calls would require
16//! `OPENAI_API_KEY` and are out of scope for the default workflow.
17
18use 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/// Client targeting an OpenAI-compatible `/chat/completions` endpoint.
29#[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    /// Build a client with the default OpenAI base URL.
40    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    /// Build a client pointed at a specific OpenAI-compatible base URL
45    /// (e.g. a local proxy or a non-OpenAI gateway). The base URL is
46    /// normalised by trimming any trailing slash.
47    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    /// Build a client with a pre-configured [`reqwest::Client`].
56    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    /// Override the `json_schema.name` field sent in the request body.
73    /// OpenAI requires a non-empty identifier; some gateways are
74    /// stricter than others about what they accept.
75    pub fn with_schema_name(mut self, name: impl Into<String>) -> Self {
76        self.schema_name = name.into();
77        self
78    }
79
80    /// The model identifier this client is bound to.
81    pub fn model(&self) -> &str {
82        &self.model
83    }
84
85    /// The base URL this client is bound to (without trailing slash).
86    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}