1mod 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
37pub const GPT_5_4: &str = "gpt-5.4";
39pub const GPT_5_4_PRO: &str = "gpt-5.4-pro";
41pub const GPT_5_3_CODEX: &str = "gpt-5.3-codex";
43pub const GPT_5_3_CODEX_SPARK: &str = "gpt-5.3-codex-spark";
45pub const GPT_5_3_INSTANT: &str = "gpt-5.3-instant";
47pub 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 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 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 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 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 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 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 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 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 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 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 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
670pub 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 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 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 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 #[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}