Skip to main content

rig_core/providers/chatgpt/
mod.rs

1//! ChatGPT subscription OAuth provider.
2//!
3//! This provider targets the ChatGPT subscription backend exposed at
4//! `https://chatgpt.com/backend-api/codex`.
5//!
6//! # Example
7//! ```no_run
8//! use rig_core::client::{CompletionClient, ProviderClient};
9//! use rig_core::providers::chatgpt;
10//!
11//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
12//! let client = chatgpt::Client::from_env()?;
13//! let model = client.completion_model(chatgpt::GPT_5_3_CODEX);
14//! # let _ = model;
15//! # Ok(())
16//! # }
17//! ```
18
19mod 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
41/// `gpt-5.4`
42pub const GPT_5_4: &str = "gpt-5.4";
43/// `gpt-5.4-pro`
44pub const GPT_5_4_PRO: &str = "gpt-5.4-pro";
45/// `gpt-5.3-codex`
46pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
47/// `gpt-5.3-codex-spark`
48pub const GPT_5_3_CODEX_SPARK: &str = "gpt-5.3-codex-spark";
49/// `gpt-5.3-instant`
50pub const GPT_5_3_INSTANT: &str = "gpt-5.3-instant";
51/// `gpt-5.3-chat-latest`
52pub 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}