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