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::{self, ApiKey, DebugExt, Provider, ProviderBuilder, ProviderClient, Transport};
22use crate::completion::{self, CompletionError, NormalizeCompletionResponse};
23use crate::http_client::{self, HttpClientExt};
24use crate::providers::openai::responses_api::{
25    self, CompletionRequest as ResponsesRequest, Include,
26};
27use crate::streaming::StreamingCompletionResponse;
28use crate::telemetry::{CompletionOperation, CompletionSpanBuilder, SpanCombinator};
29use crate::wasm_compat::{WasmCompatSend, WasmCompatSync};
30use std::fmt::Debug;
31use std::path::{Path, PathBuf};
32
33const CHATGPT_API_BASE_URL: &str = "https://chatgpt.com/backend-api/codex";
34const DEFAULT_ORIGINATOR: &str = "rig";
35const DEFAULT_INSTRUCTIONS: &str = "You are ChatGPT, a helpful AI assistant.";
36
37/// `gpt-5.4`
38pub const GPT_5_4: &str = "gpt-5.4";
39/// `gpt-5.4-pro`
40pub const GPT_5_4_PRO: &str = "gpt-5.4-pro";
41/// `gpt-5.3-codex`
42pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
43/// `gpt-5.3-codex-spark`
44pub const GPT_5_3_CODEX_SPARK: &str = "gpt-5.3-codex-spark";
45/// `gpt-5.3-instant`
46pub const GPT_5_3_INSTANT: &str = "gpt-5.3-instant";
47/// `gpt-5.3-chat-latest`
48pub const GPT_5_3_CHAT_LATEST: &str = "gpt-5.3-chat-latest";
49
50#[derive(Clone)]
51pub enum ChatGPTAuth {
52    AccessToken {
53        access_token: String,
54        account_id: Option<String>,
55    },
56    OAuth,
57}
58
59impl ApiKey for ChatGPTAuth {}
60
61impl<S> From<S> for ChatGPTAuth
62where
63    S: Into<String>,
64{
65    fn from(value: S) -> Self {
66        Self::AccessToken {
67            access_token: value.into(),
68            account_id: None,
69        }
70    }
71}
72
73impl Debug for ChatGPTAuth {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Self::AccessToken { .. } => f.write_str("AccessToken(<redacted>)"),
77            Self::OAuth => f.write_str("OAuth"),
78        }
79    }
80}
81
82#[derive(Debug, Clone)]
83pub struct ChatGPTBuilder {
84    auth_file: Option<PathBuf>,
85    default_instructions: Option<String>,
86    device_code_handler: auth::DeviceCodeHandler,
87    allow_device_flow: bool,
88    originator: String,
89    user_agent: Option<String>,
90}
91
92#[derive(Clone)]
93pub struct ChatGPTExt {
94    auth: auth::Authenticator,
95    default_instructions: Option<String>,
96    originator: String,
97    user_agent: String,
98}
99
100impl Debug for ChatGPTExt {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("ChatGPTExt")
103            .field("auth", &self.auth)
104            .field("default_instructions", &self.default_instructions)
105            .field("originator", &self.originator)
106            .field("user_agent", &self.user_agent)
107            .finish()
108    }
109}
110
111pub type Client<H = reqwest::Client> = client::Client<ChatGPTExt, H>;
112pub type ClientBuilder<H = crate::markers::Missing> =
113    client::ClientBuilder<ChatGPTBuilder, ChatGPTAuth, H>;
114
115impl Default for ChatGPTBuilder {
116    fn default() -> Self {
117        Self {
118            auth_file: default_auth_file(),
119            default_instructions: Some(
120                std::env::var("CHATGPT_DEFAULT_INSTRUCTIONS")
121                    .ok()
122                    .filter(|value| !value.trim().is_empty())
123                    .unwrap_or_else(|| DEFAULT_INSTRUCTIONS.to_string()),
124            ),
125            device_code_handler: auth::DeviceCodeHandler::default(),
126            allow_device_flow: true,
127            originator: std::env::var("CHATGPT_ORIGINATOR")
128                .ok()
129                .filter(|value| !value.is_empty())
130                .unwrap_or_else(|| DEFAULT_ORIGINATOR.to_string()),
131            user_agent: std::env::var("CHATGPT_USER_AGENT")
132                .ok()
133                .filter(|value| !value.is_empty()),
134        }
135    }
136}
137
138impl Provider for ChatGPTExt {
139    type Builder = ChatGPTBuilder;
140
141    const VERIFY_PATH: &'static str = "";
142
143    fn with_custom(&self, req: http_client::Builder) -> http_client::Result<http_client::Builder> {
144        Ok(req
145            .header("originator", &self.originator)
146            .header("user-agent", &self.user_agent)
147            .header(http::header::ACCEPT, "text/event-stream"))
148    }
149
150    fn build_uri(&self, base_url: &str, path: &str, _transport: Transport) -> String {
151        format!(
152            "{}/{}",
153            base_url.trim_end_matches('/'),
154            path.trim_start_matches('/')
155        )
156    }
157}
158
159impl responses_api::ResponsesProviderExt for ChatGPTExt {
160    // The ChatGPT backend rejects the `system` role in `input`, so every
161    // system message — including mid-conversation ones — is lifted into the
162    // top-level `instructions` field.
163    fn system_instructions_placement(&self) -> responses_api::SystemInstructionsPlacement {
164        responses_api::SystemInstructionsPlacement::AllInstructions
165    }
166}
167
168client::impl_capabilities!(ChatGPTExt, completion = ResponsesCompletionModel<H>);
169
170impl DebugExt for ChatGPTExt {}
171
172impl ProviderBuilder for ChatGPTBuilder {
173    type Extension<H>
174        = ChatGPTExt
175    where
176        H: HttpClientExt;
177    type ApiKey = ChatGPTAuth;
178
179    const BASE_URL: &'static str = CHATGPT_API_BASE_URL;
180
181    fn build<H>(
182        builder: &client::ClientBuilder<Self, Self::ApiKey, H>,
183    ) -> http_client::Result<Self::Extension<H>>
184    where
185        H: HttpClientExt,
186    {
187        let auth = match builder.get_api_key() {
188            ChatGPTAuth::AccessToken {
189                access_token,
190                account_id,
191            } => auth::AuthSource::AccessToken {
192                access_token: access_token.clone(),
193                account_id: account_id.clone(),
194            },
195            ChatGPTAuth::OAuth => auth::AuthSource::OAuth,
196        };
197
198        let ext = builder.ext();
199
200        Ok(ChatGPTExt {
201            auth: auth::Authenticator::new(
202                auth,
203                ext.auth_file.clone(),
204                ext.device_code_handler.clone(),
205                ext.allow_device_flow,
206            ),
207            default_instructions: ext.default_instructions.clone(),
208            originator: ext.originator.clone(),
209            user_agent: ext.user_agent.clone().unwrap_or_else(default_user_agent),
210        })
211    }
212}
213
214impl ProviderClient for Client {
215    type Input = ChatGPTAuth;
216    type Error = crate::client::ProviderClientError;
217
218    fn from_env() -> Result<Self, Self::Error> {
219        let mut builder = Self::builder();
220
221        if let Some(base_url) = crate::client::optional_env_var("CHATGPT_API_BASE")?
222            .or(crate::client::optional_env_var("OPENAI_CHATGPT_API_BASE")?)
223        {
224            builder = builder.base_url(base_url);
225        }
226
227        if let Some(access_token) = crate::client::optional_env_var("CHATGPT_ACCESS_TOKEN")? {
228            let account_id = crate::client::optional_env_var("CHATGPT_ACCOUNT_ID")?;
229            builder
230                .api_key(ChatGPTAuth::AccessToken {
231                    access_token,
232                    account_id,
233                })
234                .build()
235                .map_err(Into::into)
236        } else {
237            builder.oauth().build().map_err(Into::into)
238        }
239    }
240
241    fn from_val(input: Self::Input) -> Result<Self, Self::Error> {
242        Self::builder().api_key(input).build().map_err(Into::into)
243    }
244}
245
246impl<H> client::ClientBuilder<ChatGPTBuilder, crate::markers::Missing, H> {
247    pub fn oauth(self) -> client::ClientBuilder<ChatGPTBuilder, ChatGPTAuth, H> {
248        self.api_key(ChatGPTAuth::OAuth)
249    }
250}
251
252impl<H> ClientBuilder<H> {
253    pub fn on_device_code<F>(self, handler: F) -> Self
254    where
255        F: Fn(auth::DeviceCodePrompt) + Send + Sync + 'static,
256    {
257        self.over_ext(|mut ext| {
258            ext.device_code_handler = auth::DeviceCodeHandler::new(handler);
259            ext
260        })
261    }
262
263    /// Control whether OAuth may fall back to an interactive device-code login
264    /// when the cached token is missing or cannot be refreshed.
265    ///
266    /// Default is `true` for CLI-style interactive use. Long-running services
267    /// should set this to `false` so a stale refresh token returns an actionable
268    /// auth error instead of printing a device code and waiting unattended.
269    pub fn allow_device_flow(self, allow: bool) -> Self {
270        self.over_ext(|mut ext| {
271            ext.allow_device_flow = allow;
272            ext
273        })
274    }
275
276    pub fn token_dir(self, path: impl AsRef<Path>) -> Self {
277        let auth_file = path.as_ref().join("auth.json");
278        self.over_ext(|mut ext| {
279            ext.auth_file = Some(auth_file);
280            ext
281        })
282    }
283
284    pub fn auth_file(self, path: impl AsRef<Path>) -> Self {
285        let auth_file = path.as_ref().to_path_buf();
286        self.over_ext(|mut ext| {
287            ext.auth_file = Some(auth_file);
288            ext
289        })
290    }
291
292    pub fn default_instructions(self, instructions: impl Into<String>) -> Self {
293        let instructions = instructions.into();
294        self.over_ext(|mut ext| {
295            ext.default_instructions = Some(instructions);
296            ext
297        })
298    }
299
300    pub fn originator(self, originator: impl Into<String>) -> Self {
301        let originator = originator.into();
302        self.over_ext(|mut ext| {
303            ext.originator = originator;
304            ext
305        })
306    }
307
308    pub fn user_agent(self, user_agent: impl Into<String>) -> Self {
309        let user_agent = user_agent.into();
310        self.over_ext(|mut ext| {
311            ext.user_agent = Some(user_agent);
312            ext
313        })
314    }
315}
316
317#[derive(Clone)]
318pub struct ResponsesCompletionModel<H = reqwest::Client> {
319    client: Client<H>,
320    pub model: String,
321    pub tools: Vec<responses_api::ResponsesToolDefinition>,
322    pub strict_tools: bool,
323}
324
325impl<H> ResponsesCompletionModel<H>
326where
327    Client<H>: HttpClientExt + Clone + Debug + 'static,
328    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
329{
330    pub fn new(client: Client<H>, model: impl Into<String>) -> Self {
331        Self {
332            client,
333            model: model.into(),
334            tools: Vec::new(),
335            strict_tools: false,
336        }
337    }
338
339    /// Enable strict mode for function tool schemas.
340    pub fn with_strict_tools(mut self) -> Self {
341        self.strict_tools = true;
342        self
343    }
344
345    pub fn with_tool(mut self, tool: impl Into<responses_api::ResponsesToolDefinition>) -> Self {
346        self.tools.push(tool.into());
347        self
348    }
349
350    pub fn with_tools<I, Tool>(mut self, tools: I) -> Self
351    where
352        I: IntoIterator<Item = Tool>,
353        Tool: Into<responses_api::ResponsesToolDefinition>,
354    {
355        self.tools.extend(tools.into_iter().map(Into::into));
356        self
357    }
358
359    fn openai_model(&self) -> responses_api::GenericResponsesCompletionModel<ChatGPTExt, H> {
360        let mut model = responses_api::GenericResponsesCompletionModel::new(
361            self.client.clone(),
362            self.model.clone(),
363        );
364        model.tools = self.tools.clone();
365        model.strict_tools = self.strict_tools;
366        model
367    }
368
369    fn create_request(
370        &self,
371        request: completion::CompletionRequest,
372    ) -> Result<ResponsesRequest, CompletionError> {
373        let mut request = self.openai_model().create_completion_request(request)?;
374
375        if let Some(default_instructions) = &self.client.ext().default_instructions {
376            request.instructions = Some(merge_instructions(
377                default_instructions,
378                request.instructions.as_deref(),
379            ));
380        }
381
382        request.temperature = None;
383        request.max_output_tokens = None;
384        request.stream = Some(true);
385
386        let include = request
387            .additional_parameters
388            .include
389            .get_or_insert_with(Vec::new);
390        if !include
391            .iter()
392            .any(|item| matches!(item, Include::ReasoningEncryptedContent))
393        {
394            include.push(Include::ReasoningEncryptedContent);
395        }
396
397        request.additional_parameters.background = None;
398        request.additional_parameters.metadata.clear();
399        request.additional_parameters.parallel_tool_calls = None;
400        request.additional_parameters.service_tier = None;
401        request.additional_parameters.store = Some(false);
402        request.additional_parameters.text = None;
403        request.additional_parameters.top_p = None;
404        request.additional_parameters.user = None;
405
406        Ok(request)
407    }
408
409    fn add_auth_headers(
410        &self,
411        req: http_client::Builder,
412        context: &auth::AuthContext,
413    ) -> http_client::Builder {
414        let req = req
415            .header(
416                http::header::AUTHORIZATION,
417                format!("Bearer {}", context.access_token),
418            )
419            .header("session_id", crate::id::generate());
420
421        if let Some(account_id) = &context.account_id {
422            req.header("ChatGPT-Account-Id", account_id)
423        } else {
424            req
425        }
426    }
427
428    /// Execute a ChatGPT completion and return the Responses API's own wire
429    /// response.
430    ///
431    /// This is the escape hatch for fields rig does not normalize, and it
432    /// issues the same single request the normalized path does.
433    ///
434    /// One caveat is specific to this provider: `/responses` answers with an
435    /// SSE body even for a non-streaming request, so the value returned here is
436    /// reassembled from the terminal `response.completed` event. That event
437    /// sometimes carries an empty `output`, in which case the assistant content
438    /// exists only in the preceding events and
439    /// [`completion::CompletionModel::completion`] rebuilds it from them. When
440    /// you need the provider's events in full fidelity rather than just its
441    /// terminal record, use [`ResponsesCompletionModel::raw_stream`].
442    pub async fn raw_completion(
443        &self,
444        completion_request: completion::CompletionRequest,
445    ) -> Result<responses_api::CompletionResponse, CompletionError> {
446        let record_telemetry_content = completion_request.record_telemetry_content;
447        let request = self.create_request(completion_request)?;
448        let span = self.completion_span(&request, record_telemetry_content);
449
450        tracing_futures::Instrument::instrument(
451            async move { Ok(self.send_completion(request).await?.0) },
452            span,
453        )
454        .await
455    }
456
457    /// Build the `chat` span for a non-streaming ChatGPT completion.
458    ///
459    /// The instructions recorded here are the ones actually sent: the request's
460    /// merged `instructions`, not the caller's preamble, which
461    /// `SystemInstructionsPlacement::AllInstructions` folds together with the
462    /// client's `default_instructions`.
463    fn completion_span(
464        &self,
465        request: &ResponsesRequest,
466        record_telemetry_content: bool,
467    ) -> tracing::Span {
468        CompletionSpanBuilder::new(PROVIDER_NAME, &request.model, CompletionOperation::Chat)
469            .system_instructions(request.instructions.as_deref(), record_telemetry_content)
470            .build()
471    }
472
473    /// Issue the request and return the reassembled wire response together with
474    /// the SSE body it came from.
475    ///
476    /// Both the raw and the normalized path go through here, so there is one
477    /// transport, one status check, and one parse — and the normalized path can
478    /// still reach the event stream for its empty-`output` fallback without
479    /// issuing a second request.
480    async fn send_completion(
481        &self,
482        request: ResponsesRequest,
483    ) -> Result<(responses_api::CompletionResponse, String), CompletionError> {
484        let body = serde_json::to_vec(&request)?;
485        let auth = self
486            .client
487            .ext()
488            .auth
489            .auth_context()
490            .await
491            .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
492
493        let req = self
494            .add_auth_headers(self.client.post("/responses")?, &auth)
495            .body(body)
496            .map_err(|err| CompletionError::HttpError(err.into()))?;
497
498        let response = self.client.send(req).await?;
499        let status = response.status();
500        let text = http_client::text(response).await?;
501        if !status.is_success() {
502            return Err(CompletionError::from_http_response(status, text));
503        }
504
505        // The `/responses` endpoint answers with an SSE body even for a
506        // non-streaming request, so the wire response is reassembled from the
507        // event stream rather than parsed as one JSON document.
508        let raw_response = responses_api::streaming::parse_sse_completion_body(&text, "ChatGPT")?;
509
510        let span = tracing::Span::current();
511        span.record_response_metadata(&raw_response);
512
513        Ok((raw_response, text))
514    }
515
516    /// Normalize a ChatGPT completion, falling back to the SSE event stream
517    /// when the reassembled response carries no output items.
518    ///
519    /// The captured `raw` is `raw_response` — what
520    /// [`ResponsesCompletionModel::raw_completion`] returns — on both
521    /// branches, so the empty-output fallback carries it too.
522    async fn normalized_completion(
523        &self,
524        request: ResponsesRequest,
525    ) -> Result<completion::CompletionResponse, CompletionError> {
526        let (raw_response, text) = self.send_completion(request).await?;
527        let captured = serde_json::to_value(&raw_response)?;
528
529        let response = match raw_response.clone().normalize(PROVIDER_NAME) {
530            Ok(response) => response,
531            // An empty `output` means the terminal event never carried the
532            // assembled items; rebuild the response from the raw event stream.
533            Err(CompletionError::ResponseError(_)) if raw_response.output.is_empty() => {
534                responses_api::streaming::completion_response_from_sse_body(
535                    PROVIDER_NAME,
536                    &text,
537                    raw_response,
538                )
539                .await?
540            }
541            Err(error) => return Err(error),
542        };
543        Ok(response.with_raw(captured))
544    }
545}
546
547impl<H> Client<H>
548where
549    H: HttpClientExt + Clone + Debug + Default + WasmCompatSend + WasmCompatSync + 'static,
550{
551    pub async fn authorize(&self) -> Result<(), auth::AuthError> {
552        self.ext().auth.auth_context().await.map(|_| ())
553    }
554}
555
556impl<H> crate::client::ConstructCompletionModel<Client<H>> for ResponsesCompletionModel<H>
557where
558    Client<H>: HttpClientExt + Clone + Debug + 'static,
559    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
560{
561    fn construct(client: &Client<H>, model: String) -> Self {
562        Self::new(client.clone(), model)
563    }
564}
565
566impl<H> completion::CompletionModel for ResponsesCompletionModel<H>
567where
568    Client<H>: HttpClientExt + Clone + Debug + 'static,
569    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
570{
571    async fn completion(
572        &self,
573        completion_request: completion::CompletionRequest,
574    ) -> Result<completion::CompletionResponse, CompletionError> {
575        let record_telemetry_content = completion_request.record_telemetry_content;
576        let request = self.create_request(completion_request)?;
577        let span = self.completion_span(&request, record_telemetry_content);
578
579        tracing_futures::Instrument::instrument(
580            async move {
581                let response = self.normalized_completion(request).await?;
582                let span = tracing::Span::current();
583                span.record_token_usage(&response.usage);
584                Ok(response)
585            },
586            span,
587        )
588        .await
589    }
590
591    async fn stream(
592        &self,
593        completion_request: completion::CompletionRequest,
594    ) -> Result<StreamingCompletionResponse, CompletionError> {
595        Self::stream(self, completion_request).await
596    }
597}
598
599impl<H> ResponsesCompletionModel<H>
600where
601    Client<H>: HttpClientExt + Clone + Debug + 'static,
602    H: Clone + Default + Debug + WasmCompatSend + WasmCompatSync + 'static,
603{
604    /// Open a stream normalized to rig's terminal record.
605    ///
606    /// Delegates to [`ResponsesCompletionModel::raw_stream`] — one request
607    /// either way.
608    pub async fn stream(
609        &self,
610        completion_request: completion::CompletionRequest,
611    ) -> Result<StreamingCompletionResponse, CompletionError> {
612        let raw = self.raw_stream(completion_request).await?;
613
614        Ok(responses_api::streaming::normalize_responses_stream(
615            PROVIDER_NAME,
616            raw,
617        ))
618    }
619
620    /// Open a stream whose terminal record stays the Responses API's own type.
621    pub async fn raw_stream(
622        &self,
623        completion_request: completion::CompletionRequest,
624    ) -> Result<
625        crate::streaming::RawStreamingResult<responses_api::streaming::StreamingCompletionResponse>,
626        CompletionError,
627    > {
628        let record_telemetry_content = completion_request.record_telemetry_content;
629        let request = self.create_request(completion_request)?;
630
631        crate::providers::internal::trace_json(
632            crate::providers::internal::LogTarget::Completions,
633            "ChatGPT Responses streaming completion request",
634            &request,
635        );
636
637        let body = serde_json::to_vec(&request)?;
638        let auth = self
639            .client
640            .ext()
641            .auth
642            .auth_context()
643            .await
644            .map_err(|err| CompletionError::ProviderError(err.to_string()))?;
645
646        let req = self
647            .add_auth_headers(self.client.post("/responses")?, &auth)
648            .body(body)
649            .map_err(|err| CompletionError::HttpError(err.into()))?;
650
651        let span = CompletionSpanBuilder::new(
652            PROVIDER_NAME,
653            &request.model,
654            CompletionOperation::ChatStreaming,
655        )
656        .system_instructions(request.instructions.as_deref(), record_telemetry_content)
657        .build();
658
659        let client = self.client.clone();
660        let event_source = crate::http_client::sse::GenericEventSource::new(client, req)
661            .allow_missing_content_type();
662
663        Ok(responses_api::streaming::raw_stream_from_event_source(
664            event_source,
665            span,
666        ))
667    }
668}
669
670/// Stable descriptor name reported on normalized ChatGPT responses.
671pub const PROVIDER_NAME: &str = "chatgpt";
672
673fn default_user_agent() -> String {
674    format!(
675        "rig/{} ({} {}; {})",
676        env!("CARGO_PKG_VERSION"),
677        std::env::consts::OS,
678        std::env::consts::ARCH,
679        DEFAULT_ORIGINATOR
680    )
681}
682
683fn default_auth_file() -> Option<PathBuf> {
684    config_dir().map(|dir| dir.join("chatgpt").join("auth.json"))
685}
686
687use crate::providers::internal::auth::config_dir;
688
689fn merge_instructions(default_instructions: &str, existing_instructions: Option<&str>) -> String {
690    match existing_instructions
691        .map(str::trim)
692        .filter(|value| !value.is_empty())
693    {
694        Some(existing) if existing.contains(default_instructions) => existing.to_string(),
695        Some(existing) => format!("{default_instructions}\n\n{existing}"),
696        None => default_instructions.to_string(),
697    }
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn test_parse_chatgpt_sse_completion() {
706        let body = r#"data: {"type":"response.output_text.delta","delta":"hi"}
707data: {"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":[]}}
708data: [DONE]"#;
709
710        let response = responses_api::streaming::parse_sse_completion_body(body, "ChatGPT")
711            .expect("expected response");
712        assert_eq!(response.id, "resp_1");
713        assert_eq!(response.model, "gpt-5");
714    }
715
716    #[test]
717    fn test_client_initialization() {
718        let _client = crate::providers::chatgpt::Client::builder()
719            .oauth()
720            .build()
721            .expect("Client::builder()");
722    }
723
724    #[test]
725    fn test_merge_instructions_uses_default_when_missing() {
726        assert_eq!(
727            merge_instructions(DEFAULT_INSTRUCTIONS, None),
728            DEFAULT_INSTRUCTIONS
729        );
730    }
731
732    #[test]
733    fn test_merge_instructions_appends_existing_request_instructions() {
734        let merged = merge_instructions(DEFAULT_INSTRUCTIONS, Some("Respond tersely."));
735        assert!(merged.starts_with(DEFAULT_INSTRUCTIONS));
736        assert!(merged.ends_with("Respond tersely."));
737    }
738
739    #[test]
740    fn test_merge_instructions_avoids_duplicate_default() {
741        let merged = merge_instructions(
742            DEFAULT_INSTRUCTIONS,
743            Some("You are ChatGPT, a helpful AI assistant.\n\nRespond tersely."),
744        );
745        assert_eq!(
746            merged,
747            "You are ChatGPT, a helpful AI assistant.\n\nRespond tersely."
748        );
749    }
750
751    fn chatgpt_conversion_request(chat_history: Vec<completion::Message>) -> ResponsesRequest {
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        model
759            .openai_model()
760            .create_completion_request(completion::CompletionRequest {
761                model: Some("gpt-5.4".to_string()),
762                preamble: Some("System one".to_string()),
763                chat_history,
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                record_telemetry_content: false,
772            })
773            .expect("request")
774    }
775
776    #[test]
777    fn test_conversion_lifts_leading_system_messages_into_instructions() {
778        let request = chatgpt_conversion_request(vec![
779            completion::Message::system("System two"),
780            completion::Message::user("hi"),
781        ]);
782
783        assert_eq!(
784            request.instructions.as_deref(),
785            Some("System one\n\nSystem two")
786        );
787        assert_eq!(request.input.len(), 1);
788    }
789
790    #[test]
791    fn test_conversion_lifts_mid_conversation_system_messages() {
792        let request = chatgpt_conversion_request(vec![
793            completion::Message::user("hi"),
794            completion::Message::system("Mid-conversation instruction"),
795            completion::Message::user("again"),
796        ]);
797
798        assert_eq!(
799            request.instructions.as_deref(),
800            Some("System one\n\nMid-conversation instruction")
801        );
802        assert_eq!(request.input.len(), 2);
803    }
804
805    #[test]
806    fn test_create_request_merges_default_and_request_instructions() {
807        let client = crate::providers::chatgpt::Client::builder()
808            .oauth()
809            .build()
810            .expect("client");
811        let model = ResponsesCompletionModel::new(client, GPT_5_3_CODEX);
812
813        let request = model
814            .create_request(completion::CompletionRequest {
815                record_telemetry_content: false,
816                model: None,
817                preamble: Some("Respond tersely.".to_string()),
818                chat_history: vec![completion::Message::user("hello")],
819                documents: Vec::new(),
820                tools: Vec::new(),
821                temperature: None,
822                max_tokens: None,
823                tool_choice: None,
824                additional_params: None,
825                output_schema: None,
826            })
827            .expect("request");
828
829        let expected = format!("{DEFAULT_INSTRUCTIONS}\n\nRespond tersely.");
830        assert_eq!(request.instructions.as_deref(), Some(expected.as_str()));
831    }
832
833    #[test]
834    fn test_create_request_drops_temperature() {
835        let client = crate::providers::chatgpt::Client::builder()
836            .oauth()
837            .build()
838            .expect("client");
839        let model = ResponsesCompletionModel::new(client, GPT_5_3_CODEX);
840
841        let request = model
842            .create_request(completion::CompletionRequest {
843                model: None,
844                preamble: None,
845                chat_history: vec![completion::Message::user("hello")],
846                documents: Vec::new(),
847                tools: Vec::new(),
848                temperature: Some(0.5),
849                max_tokens: None,
850                tool_choice: None,
851                additional_params: None,
852                output_schema: None,
853                record_telemetry_content: false,
854            })
855            .expect("request");
856
857        assert!(request.temperature.is_none());
858    }
859
860    #[tokio::test]
861    async fn test_completion_response_from_sse_body_falls_back_to_streamed_text() {
862        let body = r#"data: {"type":"response.output_text.delta","delta":"hi"}
863data: {"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":[]}}
864data: [DONE]"#;
865
866        let raw_response = responses_api::streaming::parse_sse_completion_body(body, "ChatGPT")
867            .expect("expected response");
868        let response = responses_api::streaming::completion_response_from_sse_body(
869            PROVIDER_NAME,
870            body,
871            raw_response,
872        )
873        .await
874        .expect("fallback response");
875
876        let text: String = response
877            .choice
878            .iter()
879            .filter_map(|content| match content {
880                completion::AssistantContent::Text(text) => Some(text.text.as_str()),
881                _ => None,
882            })
883            .collect();
884
885        assert_eq!(text, "hi");
886        assert_eq!(response.usage.total_tokens, 2);
887    }
888
889    #[tokio::test]
890    async fn completion_http_non_success_preserves_status_and_body() {
891        use crate::client::CompletionClient;
892        use crate::completion::CompletionModel;
893        use crate::test_utils::RecordingHttpClient;
894
895        let cases = [
896            (
897                http::StatusCode::UNAUTHORIZED,
898                r#"{"error":{"message":"expired access token","type":"invalid_request_error"}}"#,
899                "expired access token",
900            ),
901            (
902                http::StatusCode::TOO_MANY_REQUESTS,
903                r#"{"error":{"message":"rate limited","type":"rate_limit_error"}}"#,
904                "rate limited",
905            ),
906        ];
907
908        for (status, body, message) in cases {
909            let http_client = RecordingHttpClient::with_error_response(status, body);
910            let client = crate::providers::chatgpt::Client::builder()
911                .api_key(ChatGPTAuth::AccessToken {
912                    access_token: "test-token".to_string(),
913                    account_id: Some("account-id".to_string()),
914                })
915                .http_client(http_client)
916                .build()
917                .expect("client should build");
918            let model = client.completion_model(GPT_5_4);
919            let request = model.completion_request("hello").build();
920
921            let error = model
922                .completion(request)
923                .await
924                .expect_err("completion should fail with non-success status");
925
926            assert!(matches!(&error, CompletionError::HttpError(_)));
927            assert_eq!(error.provider_response_status(), Some(status));
928            assert_eq!(error.provider_response_body(), Some(body));
929            assert!(
930                error.to_string().contains(message),
931                "error should include provider body: {error}"
932            );
933        }
934    }
935
936    /// Raw-capture tests for the ChatGPT model — the `other` seam shape: the
937    /// `/responses` endpoint answers a non-streaming call with an SSE body, so
938    /// `raw_completion` is a wire response *reassembled* from the event
939    /// stream, and the normalized path has an empty-`output` fallback that
940    /// rebuilds the choice from that same stream. The capture must be the
941    /// reassembled `responses_api::CompletionResponse` on both branches.
942    /// Driven end to end over the recording mock transport with an access
943    /// token, the same way the error-path tests above reach `completion()`.
944    mod raw_capture {
945        use super::*;
946        use crate::client::CompletionClient;
947        use crate::completion::CompletionModel as _;
948        use crate::test_utils::RecordingHttpClient;
949
950        /// A complete turn: the terminal `response.completed` carries the
951        /// assembled output plus `service_tier`, which the normalized
952        /// response provably lacks.
953        const SSE_BODY: &str = r#"data: {"type":"response.output_text.delta","delta":"hi"}
954data: {"type":"response.completed","response":{"id":"resp_chatgpt_raw","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.4","service_tier":"default","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_chatgpt_raw","status":"completed","role":"assistant","content":[{"type":"output_text","annotations":[],"text":"hi"}]}],"tools":[]}}
955data: [DONE]"#;
956
957        /// The same turn with an empty terminal `output`: the normalized path
958        /// takes the streamed-text fallback.
959        const EMPTY_OUTPUT_SSE_BODY: &str = r#"data: {"type":"response.output_text.delta","delta":"hi"}
960data: {"type":"response.completed","response":{"id":"resp_chatgpt_raw","object":"response","created_at":1,"status":"completed","error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"model":"gpt-5.4","service_tier":"default","usage":{"input_tokens":1,"input_tokens_details":{"cached_tokens":0},"output_tokens":1,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":2},"output":[],"tools":[]}}
961data: [DONE]"#;
962
963        fn model(body: &'static str) -> ResponsesCompletionModel<RecordingHttpClient> {
964            let client = crate::providers::chatgpt::Client::builder()
965                .api_key(ChatGPTAuth::AccessToken {
966                    access_token: "test-token".to_string(),
967                    account_id: Some("account-id".to_string()),
968                })
969                .http_client(RecordingHttpClient::new(body))
970                .build()
971                .expect("client should build");
972            client.completion_model(GPT_5_4)
973        }
974
975        /// The load-bearing capture property, on both normalization branches:
976        /// `raw` is the reassembled Responses `CompletionResponse` — it
977        /// deserializes back into that type and re-serializes to the identical
978        /// value, and equals what `raw_completion` returns for the same body.
979        /// On the empty-`output` body the choice comes from the streamed text,
980        /// and the capture is still the terminal record (with its empty
981        /// `output`), because that is what `raw_completion` would have
982        /// returned.
983        #[tokio::test]
984        async fn completion_captures_raw_on_both_normalization_branches() {
985            for (body, case) in [
986                (SSE_BODY, "assembled output"),
987                (EMPTY_OUTPUT_SSE_BODY, "empty-output fallback"),
988            ] {
989                let model = model(body);
990
991                let response = model
992                    .completion(model.completion_request("hello").build())
993                    .await
994                    .expect("completion");
995                let escape_hatch = model
996                    .raw_completion(model.completion_request("hello").build())
997                    .await
998                    .expect("raw completion");
999
1000                let raw = &response.raw;
1001                let typed: responses_api::CompletionResponse =
1002                    serde_json::from_value(raw.clone()).expect("raw must deserialize");
1003                assert_eq!(
1004                    serde_json::to_value(&typed).expect("re-serialize"),
1005                    *raw,
1006                    "{case}: the capture must be exactly what the wire type serializes to"
1007                );
1008                assert_eq!(
1009                    serde_json::to_value(&escape_hatch).expect("serialize raw_completion"),
1010                    *raw,
1011                    "{case}: the capture must be what raw_completion returns"
1012                );
1013                assert_eq!(raw["service_tier"], "default", "{case}");
1014                assert_eq!(typed.id, "resp_chatgpt_raw", "{case}");
1015
1016                assert_eq!(response.usage.total_tokens, 2, "{case}");
1017                assert_eq!(
1018                    response.choice,
1019                    vec![completion::AssistantContent::text("hi")],
1020                    "{case}: both branches yield the streamed text"
1021                );
1022                assert_eq!(
1023                    response.identity().response_id.as_deref(),
1024                    Some("resp_chatgpt_raw"),
1025                    "{case}"
1026                );
1027            }
1028        }
1029    }
1030}