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