swiftide_integrations/openai/
structured_prompt.rs1use 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#[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 #[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 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 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 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 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 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 let mock_server = MockServer::start().await;
225
226 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 let config = OpenAIConfig::new().with_api_base(mock_server.uri());
273 let client = Client::with_config(config);
274
275 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 let result: serde_json::Value = ai.structured_prompt("test".into()).await.unwrap();
295 dbg!(&result);
296
297 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 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_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}