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