Skip to main content

swiftide_integrations/openai/
simple_prompt.rs

1//! This module provides an implementation of the `SimplePrompt` trait for the `OpenAI` struct.
2//! It defines an asynchronous function to interact with the `OpenAI` API, allowing prompt
3//! processing and generating responses as part of the Swiftide system.
4
5use async_openai::types::chat::ChatCompletionRequestUserMessageArgs;
6use async_trait::async_trait;
7use swiftide_core::{
8    SimplePrompt,
9    chat_completion::{Usage, errors::LanguageModelError},
10    prompt::Prompt,
11    util::debug_long_utf8,
12};
13
14use super::responses_api::{build_responses_request_from_prompt, response_to_chat_completion};
15use crate::openai::openai_error_to_language_model_error;
16
17use super::GenericOpenAI;
18use anyhow::Result;
19
20/// The `SimplePrompt` trait defines a method for sending a prompt to an AI model and receiving a
21/// response.
22#[async_trait]
23impl<
24    C: async_openai::config::Config
25        + std::default::Default
26        + Sync
27        + Send
28        + std::fmt::Debug
29        + Clone
30        + 'static,
31> SimplePrompt for GenericOpenAI<C>
32{
33    /// Sends a prompt to the `OpenAI` API and returns the response content.
34    ///
35    /// # Parameters
36    /// - `prompt`: A string slice that holds the prompt to be sent to the `OpenAI` API.
37    ///
38    /// # Returns
39    /// - `Result<String>`: On success, returns the content of the response as a `String`. On
40    ///   failure, returns an error wrapped in a `Result`.
41    ///
42    /// # Errors
43    /// - Returns an error if the model is not set in the default options.
44    /// - Returns an error if the request to the `OpenAI` API fails.
45    /// - Returns an error if the response does not contain the expected content.
46    #[cfg_attr(not(feature = "langfuse"), tracing::instrument(skip_all, err))]
47    #[cfg_attr(
48        feature = "langfuse",
49        tracing::instrument(skip_all, err, fields(langfuse.type = "GENERATION"))
50    )]
51    async fn prompt(&self, prompt: Prompt) -> Result<String, LanguageModelError> {
52        if self.is_responses_api_enabled() {
53            return self.prompt_via_responses_api(prompt).await;
54        }
55
56        // Retrieve the model from the default options, returning an error if not set.
57        let model = self
58            .default_options
59            .prompt_model
60            .as_ref()
61            .ok_or_else(|| LanguageModelError::PermanentError("Model not set".into()))?;
62
63        // Build the request to be sent to the OpenAI API.
64        let request = self
65            .chat_completion_request_defaults()
66            .model(model)
67            .messages(vec![
68                ChatCompletionRequestUserMessageArgs::default()
69                    .content(prompt.render()?)
70                    .build()
71                    .map_err(LanguageModelError::permanent)?
72                    .into(),
73            ])
74            .build()
75            .map_err(LanguageModelError::permanent)?;
76
77        // Log the request for debugging purposes.
78        tracing::trace!(
79            model = &model,
80            messages = debug_long_utf8(
81                serde_json::to_string_pretty(&request.messages.last())
82                    .map_err(LanguageModelError::permanent)?,
83                100
84            ),
85            "[SimplePrompt] Request to openai"
86        );
87
88        // Send the request to the OpenAI API and await the response.
89        // Move the request; we logged key fields above if needed.
90        let tracking_request = request.clone();
91        let response = self
92            .client
93            .chat()
94            .create(request)
95            .await
96            .map_err(openai_error_to_language_model_error)?;
97
98        let message = response
99            .choices
100            .first()
101            .and_then(|choice| choice.message.content.clone())
102            .ok_or_else(|| {
103                LanguageModelError::PermanentError("Expected content in response".into())
104            })?;
105
106        let usage = response.usage.as_ref().map(Usage::from);
107
108        self.track_completion(
109            model,
110            usage.as_ref(),
111            Some(&tracking_request),
112            Some(&response),
113        );
114
115        Ok(message)
116    }
117}
118
119impl<
120    C: async_openai::config::Config
121        + std::default::Default
122        + Sync
123        + Send
124        + std::fmt::Debug
125        + Clone
126        + 'static,
127> GenericOpenAI<C>
128{
129    async fn prompt_via_responses_api(&self, prompt: Prompt) -> Result<String, LanguageModelError> {
130        let prompt_text = prompt.render().map_err(LanguageModelError::permanent)?;
131        let model = self
132            .default_options
133            .prompt_model
134            .as_ref()
135            .ok_or_else(|| LanguageModelError::PermanentError("Model not set".into()))?;
136
137        let create_request = build_responses_request_from_prompt(self, prompt_text)?;
138
139        let response = self
140            .client
141            .responses()
142            .create(create_request.clone())
143            .await
144            .map_err(openai_error_to_language_model_error)?;
145
146        let mut completion = response_to_chat_completion(response)?;
147
148        self.track_completion(
149            model,
150            completion.usage.as_ref(),
151            Some(&create_request),
152            Some(&completion),
153        );
154
155        completion.message.take().ok_or_else(|| {
156            LanguageModelError::PermanentError("Expected content in response".into())
157        })
158    }
159}
160
161#[allow(clippy::items_after_statements)]
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::openai::OpenAI;
166    use serde_json::Value;
167    use wiremock::{
168        Mock, MockServer, Request, Respond, ResponseTemplate,
169        matchers::{method, path},
170    };
171
172    #[test_log::test(tokio::test)]
173    async fn test_prompt_errors_when_model_missing() {
174        let openai = OpenAI::builder().build().unwrap();
175        let result = openai.prompt("hello".into()).await;
176        assert!(matches!(result, Err(LanguageModelError::PermanentError(_))));
177    }
178
179    #[test_log::test(tokio::test)]
180    async fn test_prompt_via_responses_api_returns_message() {
181        let mock_server = MockServer::start().await;
182
183        let response_body = serde_json::json!({
184            "created_at": 0,
185            "id": "resp",
186            "model": "gpt-4.1-mini",
187            "object": "response",
188            "status": "completed",
189            "output": [
190                {
191                    "type": "message",
192                    "id": "msg",
193                    "role": "assistant",
194                    "status": "completed",
195                    "content": [
196                        {"type": "output_text", "text": "Hello world", "annotations": []}
197                    ]
198                }
199            ],
200            "usage": {
201                "input_tokens": 4,
202                "input_tokens_details": {"cached_tokens": 0},
203                "output_tokens": 2,
204                "output_tokens_details": {"reasoning_tokens": 0},
205                "total_tokens": 6
206            }
207        });
208
209        struct ValidatePromptRequest {
210            response: Value,
211        }
212
213        impl Respond for ValidatePromptRequest {
214            fn respond(&self, request: &Request) -> ResponseTemplate {
215                let payload: Value = serde_json::from_slice(&request.body).unwrap();
216                assert_eq!(payload["model"], self.response["model"]);
217                let items = payload["input"].as_array().expect("array input");
218                assert_eq!(items.len(), 1);
219                assert_eq!(items[0]["type"], "message");
220                ResponseTemplate::new(200).set_body_json(self.response.clone())
221            }
222        }
223
224        Mock::given(method("POST"))
225            .and(path("/responses"))
226            .respond_with(ValidatePromptRequest {
227                response: response_body,
228            })
229            .mount(&mock_server)
230            .await;
231
232        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
233        let client = async_openai::Client::with_config(config);
234
235        let openai = OpenAI::builder()
236            .client(client)
237            .default_prompt_model("gpt-4.1-mini")
238            .use_responses_api(true)
239            .build()
240            .unwrap();
241
242        let result = openai.prompt("Say hi".into()).await.unwrap();
243        assert_eq!(result, "Hello world");
244    }
245
246    #[test_log::test(tokio::test)]
247    async fn test_prompt_via_responses_api_missing_output_errors() {
248        let mock_server = MockServer::start().await;
249        let empty_response = serde_json::json!({
250            "created_at": 0,
251            "id": "resp",
252            "model": "gpt-4.1-mini",
253            "object": "response",
254            "output": [],
255            "status": "completed"
256        });
257
258        Mock::given(method("POST"))
259            .and(path("/responses"))
260            .respond_with(ResponseTemplate::new(200).set_body_json(empty_response))
261            .mount(&mock_server)
262            .await;
263
264        let config = async_openai::config::OpenAIConfig::new().with_api_base(mock_server.uri());
265        let client = async_openai::Client::with_config(config);
266
267        let openai = OpenAI::builder()
268            .client(client)
269            .default_prompt_model("gpt-4.1-mini")
270            .use_responses_api(true)
271            .build()
272            .unwrap();
273
274        let err = openai.prompt("test".into()).await.unwrap_err();
275        assert!(matches!(err, LanguageModelError::PermanentError(_)));
276    }
277}