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