Skip to main content

swiftide_integrations/openai/
structured_prompt.rs

1//! This module provides an implementation of the `StructuredPrompt` trait for the `OpenAI` struct.
2//!
3//! Unlike the other traits, `StructuredPrompt` is *not* dyn safe.
4//!
5//! Use `DynStructuredPrompt` if you need dyn dispatch. For custom implementations, if you
6//! implement `DynStructuredPrompt`, you get `StructuredPrompt` for free.
7
8use async_openai::types::{
9    chat::ChatCompletionRequestUserMessageArgs,
10    responses::{ResponseFormat, ResponseFormatJsonSchema},
11};
12use async_trait::async_trait;
13use schemars::Schema;
14use swiftide_core::{
15    DynStructuredPrompt,
16    chat_completion::{Usage, errors::LanguageModelError},
17    prompt::Prompt,
18    util::debug_long_utf8,
19};
20
21use super::responses_api::{
22    build_responses_request_from_prompt_with_schema, response_to_chat_completion,
23};
24use crate::openai::openai_error_to_language_model_error;
25
26use super::GenericOpenAI;
27use anyhow::{Context as _, Result};
28
29/// The `StructuredPrompt` trait defines a method for sending a prompt to an AI model and receiving
30/// a response.
31#[async_trait]
32impl<
33    C: async_openai::config::Config
34        + std::default::Default
35        + Sync
36        + Send
37        + std::fmt::Debug
38        + Clone
39        + 'static,
40> DynStructuredPrompt for GenericOpenAI<C>
41{
42    /// Sends a prompt to the `OpenAI` API and returns the response content.
43    ///
44    /// # Parameters
45    /// - `prompt`: A string slice that holds the prompt to be sent to the `OpenAI` API.
46    ///
47    /// # Returns
48    /// - `Result<String>`: On success, returns the content of the response as a `String`. On
49    ///   failure, returns an error wrapped in a `Result`.
50    ///
51    /// # Errors
52    /// - Returns an error if the model is not set in the default options.
53    /// - Returns an error if the request to the `OpenAI` API fails.
54    /// - Returns an error if the response does not contain the expected content.
55    #[tracing::instrument(skip_all, err)]
56    #[cfg_attr(
57        feature = "langfuse",
58        tracing::instrument(skip_all, err, fields(langfuse.type = "GENERATION"))
59    )]
60    async fn structured_prompt_dyn(
61        &self,
62        prompt: Prompt,
63        schema: Schema,
64    ) -> Result<serde_json::Value, LanguageModelError> {
65        if self.is_responses_api_enabled() {
66            return self
67                .structured_prompt_via_responses_api(prompt, schema)
68                .await;
69        }
70
71        // Retrieve the model from the default options, returning an error if not set.
72        let model = self
73            .default_options
74            .prompt_model
75            .as_ref()
76            .ok_or_else(|| LanguageModelError::PermanentError("Model not set".into()))?;
77
78        let schema_value =
79            serde_json::to_value(&schema).context("Failed to get schema as value")?;
80        let response_format = ResponseFormat::JsonSchema {
81            json_schema: ResponseFormatJsonSchema {
82                description: None,
83                name: "structured_prompt".into(),
84                schema: schema_value,
85                strict: Some(true),
86            },
87        };
88
89        // Build the request to be sent to the OpenAI API.
90        let request = self
91            .chat_completion_request_defaults()
92            .model(model)
93            .response_format(response_format)
94            .messages(vec![
95                ChatCompletionRequestUserMessageArgs::default()
96                    .content(prompt.render()?)
97                    .build()
98                    .map_err(LanguageModelError::permanent)?
99                    .into(),
100            ])
101            .build()
102            .map_err(LanguageModelError::permanent)?;
103
104        // Log the request for debugging purposes.
105        tracing::trace!(
106            model = &model,
107            messages = debug_long_utf8(
108                serde_json::to_string_pretty(&request.messages.last())
109                    .map_err(LanguageModelError::permanent)?,
110                100
111            ),
112            "[StructuredPrompt] Request to openai"
113        );
114
115        // Send the request to the OpenAI API and await the response.
116        let response = self
117            .client
118            .chat()
119            .create(request.clone())
120            .await
121            .map_err(openai_error_to_language_model_error)?;
122
123        let message = response
124            .choices
125            .first()
126            .and_then(|choice| choice.message.content.clone())
127            .ok_or_else(|| {
128                LanguageModelError::PermanentError("Expected content in response".into())
129            })?;
130
131        let usage = response.usage.as_ref().map(Usage::from);
132
133        self.track_completion(model, usage.as_ref(), Some(&request), Some(&response));
134
135        let parsed = serde_json::from_str(&message)
136            .with_context(|| format!("Failed to parse response\n {message}"))?;
137
138        // Extract and return the content of the response, returning an error if not found.
139        Ok(parsed)
140    }
141}
142
143impl<
144    C: async_openai::config::Config
145        + std::default::Default
146        + Sync
147        + Send
148        + std::fmt::Debug
149        + Clone
150        + 'static,
151> GenericOpenAI<C>
152{
153    async fn structured_prompt_via_responses_api(
154        &self,
155        prompt: Prompt,
156        schema: Schema,
157    ) -> Result<serde_json::Value, LanguageModelError> {
158        let prompt_text = prompt.render().map_err(LanguageModelError::permanent)?;
159        let model = self
160            .default_options
161            .prompt_model
162            .as_ref()
163            .ok_or_else(|| LanguageModelError::PermanentError("Model not set".into()))?;
164
165        let schema_value = serde_json::to_value(&schema)
166            .context("Failed to get schema as value")
167            .map_err(LanguageModelError::permanent)?;
168
169        let create_request =
170            build_responses_request_from_prompt_with_schema(self, prompt_text, schema_value)?;
171        let tracking_request = create_request.clone();
172
173        let response = self
174            .client
175            .responses()
176            .create(create_request)
177            .await
178            .map_err(openai_error_to_language_model_error)?;
179
180        let mut completion = response_to_chat_completion(response)?;
181
182        self.track_completion(
183            model,
184            completion.usage.as_ref(),
185            Some(&tracking_request),
186            Some(&completion),
187        );
188
189        let message = completion.message.take().ok_or_else(|| {
190            LanguageModelError::PermanentError("Expected content in response".into())
191        })?;
192
193        let parsed = serde_json::from_str(&message)
194            .with_context(|| format!("Failed to parse response\n {message}"))
195            .map_err(LanguageModelError::permanent)?;
196
197        Ok(parsed)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use crate::openai::{self, OpenAI};
204    use swiftide_core::StructuredPrompt;
205
206    use super::*;
207    use async_openai::Client;
208    use async_openai::config::OpenAIConfig;
209    use schemars::{JsonSchema, schema_for};
210    use serde::{Deserialize, Serialize};
211    use serde_json::json;
212    use wiremock::{
213        Mock, MockServer, ResponseTemplate,
214        matchers::{method, path},
215    };
216
217    #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
218    struct SimpleOutput {
219        answer: String,
220    }
221
222    async fn setup_client() -> (MockServer, OpenAI) {
223        // Start the Wiremock server
224        let mock_server = MockServer::start().await;
225
226        // Prepare the response the mock should return
227        let assistant_msg = serde_json::json!({
228            "role": "assistant",
229            "content": serde_json::to_string(&SimpleOutput {
230                answer: "42".to_owned()
231            }).unwrap(),
232        });
233
234        let body = serde_json::json!({
235          "id": "chatcmpl-B9MBs8CjcvOU2jLn4n570S5qMJKcT",
236          "object": "chat.completion",
237          "created": 123,
238          "model": "gpt-4.1-2025-04-14",
239          "choices": [
240            {
241              "index": 0,
242              "message": assistant_msg,
243              "logprobs": null,
244              "finish_reason": "stop"
245            }
246          ],
247          "usage": {
248            "prompt_tokens": 19,
249            "completion_tokens": 10,
250            "total_tokens": 29,
251            "prompt_tokens_details": {
252              "cached_tokens": 0,
253              "audio_tokens": 0
254            },
255            "completion_tokens_details": {
256              "reasoning_tokens": 0,
257              "audio_tokens": 0,
258              "accepted_prediction_tokens": 0,
259              "rejected_prediction_tokens": 0
260            }
261          },
262          "service_tier": "default"
263        });
264
265        Mock::given(method("POST"))
266            .and(path("/chat/completions"))
267            .respond_with(ResponseTemplate::new(200).set_body_json(body))
268            .mount(&mock_server)
269            .await;
270
271        // Point our client at the mock server
272        let config = OpenAIConfig::new().with_api_base(mock_server.uri());
273        let client = Client::with_config(config);
274
275        // Construct the GenericOpenAI instance
276        let opts = openai::Options {
277            prompt_model: Some("gpt-4".to_string()),
278            ..openai::Options::default()
279        };
280        (
281            mock_server,
282            OpenAI::builder()
283                .client(client)
284                .default_options(opts)
285                .build()
286                .unwrap(),
287        )
288    }
289
290    #[tokio::test]
291    async fn test_structured_prompt_with_wiremock() {
292        let (_guard, ai) = setup_client().await;
293        // Call structured_prompt
294        let result: serde_json::Value = ai.structured_prompt("test".into()).await.unwrap();
295        dbg!(&result);
296
297        // Assert
298        assert_eq!(
299            serde_json::from_value::<SimpleOutput>(result).unwrap(),
300            SimpleOutput {
301                answer: "42".into()
302            }
303        );
304    }
305
306    #[tokio::test]
307    async fn test_structured_prompt_with_wiremock_as_box() {
308        let (_guard, ai) = setup_client().await;
309        // Call structured_prompt
310        let ai: Box<dyn DynStructuredPrompt> = Box::new(ai);
311        let result: serde_json::Value = ai
312            .structured_prompt_dyn("test".into(), schema_for!(SimpleOutput))
313            .await
314            .unwrap();
315        dbg!(&result);
316
317        // Assert
318        assert_eq!(
319            serde_json::from_value::<SimpleOutput>(result).unwrap(),
320            SimpleOutput {
321                answer: "42".into()
322            }
323        );
324    }
325
326    #[test_log::test(tokio::test)]
327    async fn test_structured_prompt_via_responses_api() {
328        let mock_server = MockServer::start().await;
329
330        let response_body = json!({
331            "created_at": 0,
332            "id": "resp",
333            "model": "gpt-4.1-mini",
334            "object": "response",
335            "status": "completed",
336            "output": [
337                {
338                    "type": "message",
339                    "id": "msg",
340                    "role": "assistant",
341                    "status": "completed",
342                    "content": [
343                        {"type": "output_text", "text": serde_json::to_string(&SimpleOutput { answer: "structured".into() }).unwrap(), "annotations": []}
344                    ]
345                }
346            ],
347            "usage": {
348                "input_tokens": 10,
349                "input_tokens_details": {"cached_tokens": 0},
350                "output_tokens": 4,
351                "output_tokens_details": {"reasoning_tokens": 0},
352                "total_tokens": 14
353            }
354        });
355
356        Mock::given(method("POST"))
357            .and(path("/responses"))
358            .respond_with(ResponseTemplate::new(200).set_body_json(response_body))
359            .mount(&mock_server)
360            .await;
361
362        let config = OpenAIConfig::new().with_api_base(mock_server.uri());
363        let client = Client::with_config(config);
364
365        let openai = OpenAI::builder()
366            .client(client)
367            .default_prompt_model("gpt-4.1-mini")
368            .use_responses_api(true)
369            .build()
370            .unwrap();
371
372        let schema = schema_for!(SimpleOutput);
373        let result = openai
374            .structured_prompt_dyn("Render".into(), schema)
375            .await
376            .unwrap();
377
378        assert_eq!(
379            serde_json::from_value::<SimpleOutput>(result).unwrap(),
380            SimpleOutput {
381                answer: "structured".into(),
382            }
383        );
384    }
385
386    #[test_log::test(tokio::test)]
387    async fn test_structured_prompt_via_responses_api_invalid_json_errors() {
388        let mock_server = MockServer::start().await;
389
390        let bad_response = json!({
391            "created_at": 0,
392            "id": "resp",
393            "model": "gpt-4.1-mini",
394            "object": "response",
395            "status": "completed",
396            "output": [
397                {
398                    "type": "message",
399                    "id": "msg",
400                    "role": "assistant",
401                    "status": "completed",
402                    "content": [
403                        {"type": "output_text", "text": "not json", "annotations": []}
404                    ]
405                }
406            ]
407        });
408
409        Mock::given(method("POST"))
410            .and(path("/responses"))
411            .respond_with(ResponseTemplate::new(200).set_body_json(bad_response))
412            .mount(&mock_server)
413            .await;
414
415        let config = OpenAIConfig::new().with_api_base(mock_server.uri());
416        let client = Client::with_config(config);
417
418        let openai = OpenAI::builder()
419            .client(client)
420            .default_prompt_model("gpt-4.1-mini")
421            .use_responses_api(true)
422            .build()
423            .unwrap();
424
425        let schema = schema_for!(SimpleOutput);
426        let err = openai
427            .structured_prompt_dyn("Render".into(), schema)
428            .await
429            .unwrap_err();
430
431        assert!(matches!(err, LanguageModelError::PermanentError(_)));
432    }
433}