1mod auth;
20
21use crate::OneOrMany;
22use crate::client::{
23 self, ApiKey, Capabilities, Capable, DebugExt, Nothing, Provider, ProviderBuilder,
24 ProviderClient, Transport,
25};
26use crate::completion::{self, CompletionError};
27use crate::http_client::{self, HttpClientExt};
28use crate::providers::openai::responses_api::{
29 self, CompletionRequest as ResponsesRequest, Include,
30};
31use crate::streaming::StreamingCompletionResponse;
32use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
33use std::fmt::Debug;
34use std::path::{Path, PathBuf};
35use tracing::{Level, enabled, info_span};
36
37const CHATGPT_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
38const DEFAULT_ORIGINATOR: &str = "rig";
39const DEFAULT_INSTRUCTIONS: &str = "You are ChatGPT, a helpful AI assistant.";
40
41pub const GPT_5_4: &str = "gpt-5.4";
43pub const GPT_5_4_PRO: &str = "gpt-5.4-pro";
45pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
47pub const GPT_5_3_CODEX_SPARK: &str = "gpt-5.3-codex-spark";
49pub const GPT_5_3_INSTANT: &str = "gpt-5.3-instant";
51pub const GPT_5_3_CHAT_LATEST: &str = "gpt-5.3-chat-latest";
53
54#[derive(Clone)]
55pub enum ChatGPTAuth {
56 AccessToken {
57 access_token: String,
58 account_id: Option<String>,
59 },
60 OAuth,
61}
62
63impl ApiKey for ChatGPTAuth {}
64
65impl<S> From<S> for ChatGPTAuth
66where
67 S: Into<String>,
68{
69 fn from(value: S) -> Self {
70 Self::AccessToken {
71 access_token: value.into(),
72 account_id: None,
73 }
74 }
75}
76
77impl Debug for ChatGPTAuth {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 Self::AccessToken { .. } => f.write_str("AccessToken(<redacted>)"),
81 Self::OAuth => f.write_str("OAuth"),
82 }
83 }
84}
85
86#[derive(Debug, Clone)]
87pub struct ChatGPTBuilder {
88 auth_file: Option<PathBuf>,
89 default_instructions: Option<String>,
90 device_code_handler: auth::DeviceCodeHandler,
91 originator: String,
92 user_agent: Option<String>,
93}
94
95#[derive(Clone)]
96pub struct ChatGPTExt {
97 auth: auth::Authenticator,
98 default_instructions: Option<String>,
99 originator: String,
100 user_agent: String,
101}
102
103impl Debug for ChatGPTExt {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("ChatGPTExt")
106 .field("auth", &self.auth)
107 .field("default_instructions", &self.default_instructions)
108 .field("originator", &self.originator)
109 .field("user_agent", &self.user_agent)
110 .finish()
111 }
112}
113
114pub type Client<H = reqwest::Client> = client::Client<ChatGPTExt, H>;
115pub type ClientBuilder<H = crate::markers::Missing> =
116 client::ClientBuilder<ChatGPTBuilder, ChatGPTAuth, H>;
117
118impl Default for ChatGPTBuilder {
119 fn default() -> Self {
120 Self {
121 auth_file: default_auth_file(),
122 default_instructions: Some(
123 std::env::var("CHATGPT_DEFAULT_INSTRUCTIONS")
124 .ok()
125 .filter(|value| !value.trim().is_empty())
126 .unwrap_or_else(|| DEFAULT_INSTRUCTIONS.to_string()),
127 ),
128 device_code_handler: auth::DeviceCodeHandler::default(),
129 originator: std::env::var("CHATGPT_ORIGINATOR")
130 .ok()
131 .filter(|value| !value.is_empty())
132 .unwrap_or_else(|| DEFAULT_ORIGINATOR.to_string()),
133 user_agent: std::env::var("CHATGPT_USER_AGENT")
134 .ok()
135 .filter(|value| !value.is_empty()),
136 }
137 }
138}
139
140impl Provider for ChatGPTExt {
141 type Builder = ChatGPTBuilder;
142
143 const VERIFY_PATH: &'static str = "";
144
145 fn with_custom(&self, req: http_client::Builder) -> http_client::Result<http_client::Builder> {
146 Ok(req
147 .header("originator", &self.originator)
148 .header("user-agent", &self.user_agent)
149 .header(http::header::ACCEPT, "text/event-stream"))
150 }
151
152 fn build_uri(&self, base_url: &str, path: &str, _transport: Transport) -> String {
153 format!(
154 "{}/{}",
155 base_url.trim_end_matches('/'),
156 path.trim_start_matches('/')
157 )
158 }
159}
160
161impl<H> Capabilities<H> for ChatGPTExt {
162 type Completion = Capable<ResponsesCompletionModel<H>>;
163 type Embeddings = Nothing;
164 type Transcription = Nothing;
165 type ModelListing = Nothing;
166 #[cfg(feature = "image")]
167 type ImageGeneration = Nothing;
168 #[cfg(feature = "audio")]
169 type AudioGeneration = Nothing;
170 type Rerank = Nothing;
171}
172
173impl DebugExt for ChatGPTExt {}
174
175impl ProviderBuilder for ChatGPTBuilder {
176 type Extension<H>
177 = ChatGPTExt
178 where
179 H: HttpClientExt;
180 type ApiKey = ChatGPTAuth;
181
182 const BASE_URL: &'static str = CHATGPT_API_BASE_URL;
183
184 fn build<H>(
185 builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
186 ) -> http_client::Result<Self::Extension<H>>
187 where
188 H: HttpClientExt,
189 {
190 let auth = match builder.get_api_key() {
191 ChatGPTAuth::AccessToken {
192 access_token,
193 account_id,
194 } => auth::AuthSource::AccessToken {
195 access_token: access_token.clone(),
196 account_id: account_id.clone(),
197 },
198 ChatGPTAuth::OAuth => auth::AuthSource::OAuth,
199 };
200
201 let ext = builder.ext();
202
203 Ok(ChatGPTExt {
204 auth: auth::Authenticator::new(
205 auth,
206 ext.auth_file.clone(),
207 ext.device_code_handler.clone(),
208 ),
209 default_instructions: ext.default_instructions.clone(),
210 originator: ext.originator.clone(),
211 user_agent: ext.user_agent.clone().unwrap_or_else(default_user_agent),
212 })
213 }
214}
215
216impl ProviderClient for Client {
217 type Input = ChatGPTAuth;
218 type Error = crate::client::ProviderClientError;
219
220 fn from_env() -> Result<Self, Self::Error> {
221 let mut builder = Self::builder();
222
223 if let Some(base_url) = crate::client::optional_env_var("CHATGPT_API_BASE")?
224 .or(crate::client::optional_env_var("OPENAI_CHATGPT_API_BASE")?)
225 {
226 builder = builder.base_url(base_url);
227 }
228
229 if let Some(access_token) = crate::client::optional_env_var("CHATGPT_ACCESS_TOKEN")? {
230 let account_id = crate::client::optional_env_var("CHATGPT_ACCOUNT_ID")?;
231 builder
232 .api_key(ChatGPTAuth::AccessToken {
233 access_token,
234 account_id,
235 })
236 .build()
237 .map_err(Into::into)
238 } else {
239 builder.oauth().build().map_err(Into::into)
240 }
241 }
242
243 fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
244 Self::builder().api_key(input).build().map_err(Into::into)
245 }
246}
247
248impl<H> client::ClientBuilder<ChatGPTBuilder, crate::markers::Missing, H> {
249 pub fn oauth(self) -> client::ClientBuilder<ChatGPTBuilder, ChatGPTAuth, H> {
250 self.api_key(ChatGPTAuth::OAuth)
251 }
252}
253
254impl<H> ClientBuilder<H> {
255 pub fn on_device_code<F>(self, handler: F) -> Self
256 where
257 F: Fn(auth::DeviceCodePrompt) + Send + Sync + 'static,
258 {
259 self.over_ext(|mut ext| {
260 ext.device_code_handler = auth::DeviceCodeHandler::new(handler);
261 ext
262 })
263 }
264
265 pub fn token_dir(self, path: impl AsRef<Path>) -> Self {
266 let auth_file = path.as_ref().join("auth.json");
267 self.over_ext(|mut ext| {
268 ext.auth_file = Some(auth_file);
269 ext
270 })
271 }
272
273 pub fn auth_file(self, path: impl AsRef<Path>) -> Self {
274 let auth_file = path.as_ref().to_path_buf();
275 self.over_ext(|mut ext| {
276 ext.auth_file = Some(auth_file);
277 ext
278 })
279 }
280
281 pub fn default_instructions(self, instructions: impl Into<String>) -> Self {
282 let instructions = instructions.into();
283 self.over_ext(|mut ext| {
284 ext.default_instructions = Some(instructions);
285 ext
286 })
287 }
288
289 pub fn originator(self, originator: impl Into<String>) -> Self {
290 let originator = originator.into();
291 self.over_ext(|mut ext| {
292 ext.originator = originator;
293 ext
294 })
295 }
296
297 pub fn user_agent(self, user_agent: impl Into<String>) -> Self {
298 let user_agent = user_agent.into();
299 self.over_ext(|mut ext| {
300 ext.user_agent = Some(user_agent);
301 ext
302 })
303 }
304}
305
306#[derive(Clone)]
307pub struct ResponsesCompletionModel<H = reqwest::Client> {
308 client: Client<H>,
309 pub model: String,
310 pub tools: Vec<responses_api::ResponsesToolDefinition>,
311}
312
313impl<H> ResponsesCompletionModel<H>
314where
315 Client<H>: HttpClientExt + Clone + Debug + 'static,
316 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
317{
318 pub fn new(client: Client<H>, model: impl Into<String>) -> Self {
319 Self {
320 client,
321 model: model.into(),
322 tools: Vec::new(),
323 }
324 }
325
326 pub fn with_tool(mut self, tool: impl Into<responses_api::ResponsesToolDefinition>) -> Self {
327 self.tools.push(tool.into());
328 self
329 }
330
331 pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
332 where
333 I: IntoIterator<Item = Tool>,
334 Tool: Into<responses_api::ResponsesToolDefinition>,
335 {
336 self.tools.extend(tools.into_iter().map(Into::into));
337 self
338 }
339
340 fn openai_model(&self) -> responses_api::GenericResponsesCompletionModel<ChatGPTExt, H> {
341 let mut model = responses_api::GenericResponsesCompletionModel::new(
342 self.client.clone(),
343 self.model.clone(),
344 );
345 model.tools = self.tools.clone();
346 model
347 }
348
349 fn create_request(
350 &self,
351 request: completion::CompletionRequest,
352 ) -> Result<ResponsesRequest, CompletionError> {
353 let mut request = self.openai_model().create_completion_request(request)?;
354
355 if let Some(system_instructions) =
356 normalize_system_messages_into_instructions(&mut request)?
357 {
358 request.instructions = Some(match request.instructions.as_deref() {
359 Some(existing) if !existing.trim().is_empty() => {
360 format!("{system_instructions}\n\n{existing}")
361 }
362 _ => system_instructions,
363 });
364 }
365
366 if let Some(default_instructions) = &self.client.ext().default_instructions {
367 request.instructions = Some(merge_instructions(
368 default_instructions,
369 request.instructions.as_deref(),
370 ));
371 }
372
373 request.temperature = None;
374 request.max_output_tokens = None;
375 request.stream = Some(true);
376
377 let include = request
378 .additional_parameters
379 .include
380 .get_or_insert_with(Vec::new);
381 if !include
382 .iter()
383 .any(|item| matches!(item, Include::ReasoningEncryptedContent))
384 {
385 include.push(Include::ReasoningEncryptedContent);
386 }
387
388 request.additional_parameters.background = None;
389 request.additional_parameters.metadata.clear();
390 request.additional_parameters.parallel_tool_calls = None;
391 request.additional_parameters.service_tier = None;
392 request.additional_parameters.store = Some(false);
393 request.additional_parameters.text = None;
394 request.additional_parameters.top_p = None;
395 request.additional_parameters.user = None;
396
397 Ok(request)
398 }
399
400 fn add_auth_headers(
401 &self,
402 req: http_client::Builder,
403 context: &auth::AuthContext,
404 ) -> http_client::Builder {
405 let req = req
406 .header(
407 http::header::AUTHORIZATION,
408 format!("Bearer {}", context.access_token),
409 )
410 .header("session_id", nanoid::nanoid!());
411
412 if let Some(account_id) = &context.account_id {
413 req.header("ChatGPT-Account-Id", account_id)
414 } else {
415 req
416 }
417 }
418
419 async fn completion_from_sse(
420 &self,
421 request: ResponsesRequest,
422 ) -> Result<completion::CompletionResponse<responses_api::CompletionResponse>, CompletionError>
423 {
424 let body = serde_json::to_vec(&request)?;
425 let auth = self
426 .client
427 .ext()
428 .auth
429 .auth_context()
430 .await
431 .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
432
433 let req = self
434 .add_auth_headers(self.client.post("/responses")?, &auth)
435 .body(body)
436 .map_err(|err| CompletionError::HttpError(err.into()))?;
437
438 let response = self.client.send(req).await?;
439 let text = http_client::text(response).await?;
440 let raw_response = responses_api::streaming::parse_sse_completion_body(&text, "ChatGPT")?;
441
442 match raw_response.clone().try_into() {
443 Ok(response) => Ok(response),
444 Err(CompletionError::ResponseError(message))
445 if message == "Response contained no parts" =>
446 {
447 responses_api::streaming::completion_response_from_sse_body(
448 &text,
449 raw_response,
450 "ChatGPT",
451 )
452 .await
453 }
454 Err(error) => Err(error),
455 }
456 }
457}
458
459impl<H> Client<H>
460where
461 H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
462{
463 pub async fn authorize(&self) -> Result<(), auth::AuthError> {
464 self.ext().auth.auth_context().await.map(|_| ())
465 }
466}
467
468impl<H> completion::CompletionModel for ResponsesCompletionModel<H>
469where
470 Client<H>: HttpClientExt + Clone + Debug + 'static,
471 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
472{
473 type Response = responses_api::CompletionResponse;
474 type StreamingResponse = responses_api::streaming::StreamingCompletionResponse;
475 type Client = Client<H>;
476
477 fn make(client: &Self::Client, model: impl Into<String>) -> Self {
478 Self::new(client.clone(), model)
479 }
480
481 async fn completion(
482 &self,
483 completion_request: completion::CompletionRequest,
484 ) -> Result<completion::CompletionResponse<Self::Response>, CompletionError> {
485 let request = self.create_request(completion_request)?;
486
487 let span = if tracing::Span::current().is_disabled() {
488 info_span!(
489 target: "rig::completions",
490 "chat",
491 gen_ai.operation.name = "chat",
492 gen_ai.provider.name = "chatgpt",
493 gen_ai.request.model = self.model,
494 gen_ai.response.id = tracing::field::Empty,
495 gen_ai.response.model = tracing::field::Empty,
496 gen_ai.usage.output_tokens = tracing::field::Empty,
497 gen_ai.usage.input_tokens = tracing::field::Empty,
498 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
499 gen_ai.input.messages = tracing::field::Empty,
500 gen_ai.output.messages = tracing::field::Empty,
501 )
502 } else {
503 tracing::Span::current()
504 };
505
506 tracing_futures::Instrument::instrument(
507 async move {
508 let response = self.completion_from_sse(request).await?;
509 let span = tracing::Span::current();
510 span.record("gen_ai.response.id", &response.raw_response.id);
511 span.record("gen_ai.response.model", &response.raw_response.model);
512 span.record("gen_ai.usage.output_tokens", response.usage.output_tokens);
513 span.record("gen_ai.usage.input_tokens", response.usage.input_tokens);
514 span.record(
515 "gen_ai.usage.cache_read.input_tokens",
516 response.usage.cached_input_tokens,
517 );
518 Ok(response)
519 },
520 span,
521 )
522 .await
523 }
524
525 async fn stream(
526 &self,
527 completion_request: completion::CompletionRequest,
528 ) -> Result<StreamingCompletionResponse<Self::StreamingResponse>, CompletionError> {
529 Self::stream(self, completion_request).await
530 }
531}
532
533impl<H> ResponsesCompletionModel<H>
534where
535 Client<H>: HttpClientExt + Clone + Debug + 'static,
536 H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
537{
538 pub async fn stream(
539 &self,
540 completion_request: completion::CompletionRequest,
541 ) -> Result<
542 StreamingCompletionResponse<responses_api::streaming::StreamingCompletionResponse>,
543 CompletionError,
544 > {
545 let request = self.create_request(completion_request)?;
546
547 if enabled!(Level::TRACE) {
548 tracing::trace!(
549 target: "rig::completions",
550 "ChatGPT Responses streaming completion request: {}",
551 serde_json::to_string_pretty(&request)?
552 );
553 }
554
555 let body = serde_json::to_vec(&request)?;
556 let auth = self
557 .client
558 .ext()
559 .auth
560 .auth_context()
561 .await
562 .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
563
564 let req = self
565 .add_auth_headers(self.client.post("/responses")?, &auth)
566 .body(body)
567 .map_err(|err| CompletionError::HttpError(err.into()))?;
568
569 let span = if tracing::Span::current().is_disabled() {
570 info_span!(
571 target: "rig::completions",
572 "chat_streaming",
573 gen_ai.operation.name = "chat_streaming",
574 gen_ai.provider.name = "chatgpt",
575 gen_ai.request.model = self.model,
576 gen_ai.response.id = tracing::field::Empty,
577 gen_ai.response.model = tracing::field::Empty,
578 gen_ai.usage.output_tokens = tracing::field::Empty,
579 gen_ai.usage.input_tokens = tracing::field::Empty,
580 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
581 )
582 } else {
583 tracing::Span::current()
584 };
585
586 let client = self.client.clone();
587 let event_source = crate::http_client::sse::GenericEventSource::new(client, req)
588 .allow_missing_content_type();
589
590 Ok(responses_api::streaming::stream_from_event_source(
591 event_source,
592 span,
593 "ChatGPT",
594 ))
595 }
596}
597
598fn default_user_agent() -> String {
599 format!(
600 "rig/{} ({} {}; {})",
601 env!("CARGO_PKG_VERSION"),
602 std::env::consts::OS,
603 std::env::consts::ARCH,
604 DEFAULT_ORIGINATOR
605 )
606}
607
608fn default_auth_file() -> Option<PathBuf> {
609 config_dir().map(|dir| dir.join("chatgpt").join("auth.json"))
610}
611
612fn config_dir() -> Option<PathBuf> {
613 #[cfg(target_os = "windows")]
614 {
615 std::env::var_os("APPDATA").map(PathBuf::from)
616 }
617
618 #[cfg(not(target_os = "windows"))]
619 {
620 std::env::var_os("XDG_CONFIG_HOME")
621 .map(PathBuf::from)
622 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
623 }
624}
625
626fn normalize_system_messages_into_instructions(
627 request: &mut ResponsesRequest,
628) -> Result<Option<String>, CompletionError> {
629 let mut system_instructions = Vec::new();
630 let mut filtered_items = Vec::new();
631
632 for item in request.input.clone() {
633 if let Some(system_text) = item.system_text() {
634 let system_text = system_text.trim();
635 if !system_text.is_empty() {
636 system_instructions.push(system_text.to_string());
637 }
638 } else {
639 filtered_items.push(item);
640 }
641 }
642
643 request.input = OneOrMany::many(filtered_items).map_err(|_| {
644 CompletionError::RequestError(
645 "ChatGPT responses request input must contain at least one non-system item".into(),
646 )
647 })?;
648
649 if system_instructions.is_empty() {
650 Ok(None)
651 } else {
652 Ok(Some(system_instructions.join("\n\n")))
653 }
654}
655
656fn merge_instructions(default_instructions: &str, existing_instructions: Option<&str>) -> String {
657 match existing_instructions
658 .map(str::trim)
659 .filter(|value| !value.is_empty())
660 {
661 Some(existing) if existing.contains(default_instructions) => existing.to_string(),
662 Some(existing) => format!("{default_instructions}\n\n{existing}"),
663 None => default_instructions.to_string(),
664 }
665}
666
667#[cfg(test)]
668mod tests {
669 use super::*;
670
671 #[test]
672 fn test_parse_chatgpt_sse_completion() {
673 let body = r#"data: {"type":"response.output_text.delta","delta":"hi"}
674data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[{"type":"message","id":"msg_1","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"hi"}]}],"tools":[]}}
675data: [DONE]"#;
676
677 let response = responses_api::streaming::parse_sse_completion_body(body, "ChatGPT")
678 .expect("expected response");
679 assert_eq!(response.id, "resp_1");
680 assert_eq!(response.model, "gpt-5");
681 }
682
683 #[test]
684 fn test_client_initialization() {
685 let _client = crate::providers::chatgpt::Client::builder()
686 .oauth()
687 .build()
688 .expect("Client::builder()");
689 }
690
691 #[test]
692 fn test_merge_instructions_uses_default_when_missing() {
693 assert_eq!(
694 merge_instructions(DEFAULT_INSTRUCTIONS, None),
695 DEFAULT_INSTRUCTIONS
696 );
697 }
698
699 #[test]
700 fn test_merge_instructions_appends_existing_request_instructions() {
701 let merged = merge_instructions(DEFAULT_INSTRUCTIONS, Some("Respond tersely."));
702 assert!(merged.starts_with(DEFAULT_INSTRUCTIONS));
703 assert!(merged.ends_with("Respond tersely."));
704 }
705
706 #[test]
707 fn test_merge_instructions_avoids_duplicate_default() {
708 let merged = merge_instructions(
709 DEFAULT_INSTRUCTIONS,
710 Some("You are ChatGPT, a helpful AI assistant.\n\nRespond tersely."),
711 );
712 assert_eq!(
713 merged,
714 "You are ChatGPT, a helpful AI assistant.\n\nRespond tersely."
715 );
716 }
717
718 #[test]
719 fn test_normalize_system_messages_into_instructions() {
720 let completion_request = completion::CompletionRequest {
721 model: Some("gpt-5.4".to_string()),
722 preamble: Some("System one".to_string()),
723 chat_history: OneOrMany::many(vec![
724 completion::Message::system("System two"),
725 completion::Message::user("hi"),
726 ])
727 .expect("history"),
728 documents: Vec::new(),
729 tools: Vec::new(),
730 temperature: None,
731 max_tokens: None,
732 tool_choice: None,
733 additional_params: None,
734 output_schema: None,
735 };
736 let mut request = ResponsesRequest::try_from(("gpt-5.4".to_string(), completion_request))
737 .expect("request");
738
739 let instructions = normalize_system_messages_into_instructions(&mut request)
740 .expect("normalize")
741 .expect("instructions");
742
743 assert_eq!(instructions, "System one\n\nSystem two");
744 assert_eq!(request.input.len(), 1);
745 }
746
747 #[test]
748 fn test_create_request_drops_temperature() {
749 let client = crate::providers::chatgpt::Client::builder()
750 .oauth()
751 .build()
752 .expect("client");
753 let model = ResponsesCompletionModel::new(client, GPT_5_3_CODEX);
754
755 let request = model
756 .create_request(completion::CompletionRequest {
757 model: None,
758 preamble: None,
759 chat_history: OneOrMany::one(completion::Message::user("hello")),
760 documents: Vec::new(),
761 tools: Vec::new(),
762 temperature: Some(0.5),
763 max_tokens: None,
764 tool_choice: None,
765 additional_params: None,
766 output_schema: None,
767 })
768 .expect("request");
769
770 assert!(request.temperature.is_none());
771 }
772
773 #[tokio::test]
774 async fn test_completion_response_from_sse_body_falls_back_to_streamed_text() {
775 let body = r#"data: {"type":"response.output_text.delta","delta":"hi"}
776data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[],"tools":[]}}
777data: [DONE]"#;
778
779 let raw_response = responses_api::streaming::parse_sse_completion_body(body, "ChatGPT")
780 .expect("expected response");
781 let response = responses_api::streaming::completion_response_from_sse_body(
782 body,
783 raw_response,
784 "ChatGPT",
785 )
786 .await
787 .expect("fallback response");
788
789 let text: String = response
790 .choice
791 .iter()
792 .filter_map(|content| match content {
793 completion::AssistantContent::Text(text) => Some(text.text.as_str()),
794 _ => None,
795 })
796 .collect();
797
798 assert_eq!(text, "hi");
799 assert_eq!(response.usage.total_tokens, 2);
800 }
801}