use std::future::Future; use std::time::Duration; use crate::AuthProvider; use bytes::Bytes; use codex_http_client::HttpResponse; use codex_http_client::RequestBuilder; use codex_http_client::RouteAwareClientPool; use codex_http_client::RouteAwareRequestError; use futures::Stream; use http::Method; use http::StatusCode; use http::header::CONTENT_LENGTH; use serde::Deserialize; use tokio::time::Instant; use uuid::Uuid; pub const OPENAI_FILE_URI_PREFIX: &str = "sediment://"; pub const OPENAI_FILE_UPLOAD_LIMIT_BYTES: u64 = 512 * 1024 * 1024; const OPENAI_FILE_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const OPENAI_FILE_BLOB_UPLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60); const MAX_BLOB_UPLOAD_ATTEMPTS: u64 = 5; const OPENAI_FILE_FINALIZE_TIMEOUT: Duration = Duration::from_secs(30); const OPENAI_FILE_FINALIZE_RETRY_DELAY: Duration = Duration::from_millis(250); const OPENAI_FILE_USE_CASE: &str = "codex"; #[derive(Debug)] pub struct HostedFileUploadContext { pub connector_id: String, pub action_name: String, pub model: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct UploadedOpenAiFile { pub file_id: String, pub uri: String, pub download_url: String, pub file_name: String, pub file_size_bytes: u64, pub mime_type: Option, } #[derive(Debug, thiserror::Error)] pub enum OpenAiFileError { #[error("failed to open OpenAI file upload contents: {0}")] ReadContents(#[source] std::io::Error), #[error(transparent)] Auth(#[from] crate::AuthError), #[error( "file `{file_name}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes" )] FileTooLarge { file_name: String, size_bytes: u64, limit_bytes: u64, }, #[error("failed to send OpenAI file request to {url}: {source}")] Request { url: String, #[source] source: RouteAwareRequestError, }, #[error( "OpenAI file blob upload to {host} failed after {elapsed_ms} ms ({error_kind}, azure_client_request_id={azure_client_request_id}): {source}" )] BlobUploadRequest { host: String, elapsed_ms: u128, error_kind: &'static str, azure_client_request_id: String, #[source] source: RouteAwareRequestError, }, #[error( "OpenAI file blob upload to {host} failed with status {status} (azure_client_request_id={azure_client_request_id}, azure_request_id={azure_request_id}, azure_error_code={azure_error_code})" )] BlobUploadStatus { host: String, status: StatusCode, azure_client_request_id: String, azure_request_id: String, azure_error_code: String, }, #[error("OpenAI file request to {url} failed with status {status}: {body}")] UnexpectedStatus { url: String, status: StatusCode, body: String, }, #[error("failed to parse OpenAI file response from {url}: {source}")] Decode { url: String, #[source] source: serde_json::Error, }, #[error("OpenAI file upload for `{file_id}` is not ready yet")] UploadNotReady { file_id: String }, #[error("OpenAI file upload for `{file_id}` failed: {message}")] UploadFailed { file_id: String, message: String }, } #[derive(Deserialize)] struct CreateFileResponse { file_id: String, upload_url: String, #[serde(default)] pdf_c2pa_reservation: bool, } #[derive(Deserialize)] #[serde(rename_all = "snake_case")] struct DownloadLinkResponse { status: String, download_url: Option, file_name: Option, mime_type: Option, error_message: Option, #[serde(default)] file_size_bytes: Option, } pub fn openai_file_uri(file_id: &str) -> String { format!("{OPENAI_FILE_URI_PREFIX}{file_id}") } /// Creates and finalizes one file record, reopening the same contents for blob PUT retries. pub async fn upload_openai_file( base_url: &str, auth: &dyn AuthProvider, client_pool: &RouteAwareClientPool, file_name: String, file_size_bytes: u64, mut open_contents: Open, hosted_upload: Option<&HostedFileUploadContext>, ) -> Result where Open: FnMut() -> OpenFuture, OpenFuture: Future>, Contents: Stream> + Send + 'static, { if file_size_bytes > OPENAI_FILE_UPLOAD_LIMIT_BYTES { return Err(OpenAiFileError::FileTooLarge { file_name, size_bytes: file_size_bytes, limit_bytes: OPENAI_FILE_UPLOAD_LIMIT_BYTES, }); } let create_url = format!("{}/files", base_url.trim_end_matches('/')); let create_request = serde_json::json!({ "file_name": file_name.as_str(), "file_size": file_size_bytes, "use_case": OPENAI_FILE_USE_CASE, }); let request = authorized_request(client_pool, auth, Method::POST, &create_url)?; let create_request = match hosted_upload { Some(context) => serde_json::json!({ "file_name": file_name.as_str(), "file_size": file_size_bytes, "use_case": OPENAI_FILE_USE_CASE, "codex_connector_id": context.connector_id, "codex_action_name": context.action_name, "codex_model": context.model, }), None => create_request, }; let create_response = request .json(&create_request) .send() .await .map_err(|source| OpenAiFileError::Request { url: create_url.clone(), source, })?; let create_status = create_response.status(); let create_body = create_response .text() .await .map_err(|source| OpenAiFileError::Request { url: create_url.clone(), source, })?; if !create_status.is_success() { return Err(OpenAiFileError::UnexpectedStatus { url: create_url, status: create_status, body: create_body, }); } let create_payload: CreateFileResponse = serde_json::from_str(&create_body).map_err(|source| OpenAiFileError::Decode { url: create_url.clone(), source, })?; let upload_host = url::Url::parse(&create_payload.upload_url) .ok() .and_then(|url| url.host_str().map(str::to_owned)) .unwrap_or_else(|| "unknown-host".to_string()); let upload_started_at = Instant::now(); let deadline = upload_started_at + OPENAI_FILE_BLOB_UPLOAD_TIMEOUT; let mut retry_delay = Duration::ZERO; for attempt in 1..=MAX_BLOB_UPLOAD_ATTEMPTS { let azure_client_request_id = Uuid::new_v4().to_string(); let request_error = |source: RouteAwareRequestError| { let error_kind = if source.is_timeout() { "timeout" } else if source.is_connect() { "connect" } else if source.is_body() { "body" } else if source.is_request() { "request" } else { "other" }; OpenAiFileError::BlobUploadRequest { host: upload_host.clone(), elapsed_ms: upload_started_at.elapsed().as_millis(), error_kind, azure_client_request_id: azure_client_request_id.clone(), source: source.without_url(), } }; // Opening the stream, sending every attempt, and backoff share one deadline. let result = tokio::time::timeout_at(deadline, async { if Instant::now() >= deadline { return Err(request_error(RouteAwareRequestError::Timeout)); } let contents = open_contents() .await .map_err(OpenAiFileError::ReadContents)?; let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return Err(request_error(RouteAwareRequestError::Timeout)); } client_pool .put(&create_payload.upload_url) .timeout(remaining) .header("x-ms-blob-type", "BlockBlob") .header("x-ms-client-request-id", &azure_client_request_id) .header(CONTENT_LENGTH, file_size_bytes) .body_stream(contents) .send() .await .map_err(request_error) }) .await .unwrap_or_else(|_| Err(request_error(RouteAwareRequestError::Timeout))); let mut status = None; let mut cloudflare_ray_id = "missing".to_string(); let mut azure_request_id = "missing".to_string(); let mut azure_error_code = "missing".to_string(); let mut server_delay = None; let (error, retryable, error_kind) = match result { Ok(response) => { let upload_status = response.status(); status = Some(upload_status.to_string()); cloudflare_ray_id = upload_response_header(&response, "cf-ray"); azure_request_id = upload_response_header(&response, "x-ms-request-id"); azure_error_code = upload_response_header(&response, "x-ms-error-code"); if upload_status.is_success() { if attempt > 1 { tracing::event!( target: "codex_otel.log_only", tracing::Level::INFO, event.name = "codex.openai_file_blob_upload_recovered", file_id = %create_payload.file_id, host = %upload_host, file_size_bytes, elapsed_ms = upload_started_at.elapsed().as_millis(), attempt, delay_ms = retry_delay.as_millis(), status = status.as_deref(), cloudflare_ray_id, azure_client_request_id, azure_request_id, azure_error_code, "OpenAI file blob upload recovered" ); } break; } server_delay = blob_retry_after(response.headers()); ( OpenAiFileError::BlobUploadStatus { host: upload_host.clone(), status: upload_status, azure_client_request_id: azure_client_request_id.clone(), azure_request_id: azure_request_id.clone(), azure_error_code: azure_error_code.clone(), }, matches!( upload_status, StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT ), "http_status", ) } Err(error) => { let (retryable, error_kind) = match &error { OpenAiFileError::BlobUploadRequest { source, error_kind, .. } => ( source.is_timeout() || source.is_connect() || source.is_body() || source.is_request(), *error_kind, ), _ => (false, "read"), }; (error, retryable, error_kind) } }; retry_delay = server_delay .unwrap_or_else(|| codex_client::backoff(Duration::from_millis(125), attempt)); let will_retry = retryable && attempt < MAX_BLOB_UPLOAD_ATTEMPTS && retry_delay < deadline.saturating_duration_since(Instant::now()); let event_name = if will_retry { "codex.openai_file_blob_upload_retry" } else { "codex.openai_file_blob_upload_failed" }; tracing::event!( target: "codex_otel.log_only", tracing::Level::WARN, event.name = event_name, file_id = %create_payload.file_id, host = %upload_host, file_size_bytes, elapsed_ms = upload_started_at.elapsed().as_millis(), attempt, delay_ms = if will_retry { retry_delay.as_millis() } else { 0 }, status = status.as_deref(), error_kind, cloudflare_ray_id, azure_client_request_id, azure_request_id, azure_error_code, "OpenAI file blob upload attempt failed" ); if !will_retry { return Err(error); } tokio::time::sleep(retry_delay).await; } let finalize_url = format!( "{}/files/{}/uploaded", base_url.trim_end_matches('/'), create_payload.file_id, ); let finalize_request = serde_json::json!({}); let finalize_request = if create_payload.pdf_c2pa_reservation { serde_json::json!({"pdf_c2pa_create_request": create_request}) } else { finalize_request }; let finalize_started_at = Instant::now(); loop { let finalize_response = authorized_request(client_pool, auth, Method::POST, &finalize_url)? .json(&finalize_request) .send() .await .map_err(|source| OpenAiFileError::Request { url: finalize_url.clone(), source, })?; let finalize_status = finalize_response.status(); let finalize_body = finalize_response .text() .await .map_err(|source| OpenAiFileError::Request { url: finalize_url.clone(), source, })?; if !finalize_status.is_success() { return Err(OpenAiFileError::UnexpectedStatus { url: finalize_url.clone(), status: finalize_status, body: finalize_body, }); } let finalize_payload: DownloadLinkResponse = serde_json::from_str(&finalize_body).map_err(|source| OpenAiFileError::Decode { url: finalize_url.clone(), source, })?; match finalize_payload.status.as_str() { "success" => { let file_size_bytes = finalize_payload.file_size_bytes.unwrap_or(file_size_bytes); return Ok(UploadedOpenAiFile { file_id: create_payload.file_id.clone(), uri: openai_file_uri(&create_payload.file_id), download_url: finalize_payload.download_url.ok_or_else(|| { OpenAiFileError::UploadFailed { file_id: create_payload.file_id.clone(), message: "missing download_url".to_string(), } })?, file_name: finalize_payload.file_name.unwrap_or(file_name), file_size_bytes, mime_type: finalize_payload.mime_type, }); } "retry" => { if finalize_started_at.elapsed() >= OPENAI_FILE_FINALIZE_TIMEOUT { return Err(OpenAiFileError::UploadNotReady { file_id: create_payload.file_id, }); } tokio::time::sleep(OPENAI_FILE_FINALIZE_RETRY_DELAY).await; } _ => { return Err(OpenAiFileError::UploadFailed { file_id: create_payload.file_id, message: finalize_payload .error_message .unwrap_or_else(|| "upload finalization returned an error".to_string()), }); } } } } fn authorized_request( client_pool: &RouteAwareClientPool, auth: &dyn AuthProvider, method: Method, url: &str, ) -> Result { let mut headers = http::HeaderMap::new(); auth.add_auth_headers(&mut headers)?; Ok(client_pool .request(method, url) .timeout(OPENAI_FILE_REQUEST_TIMEOUT) .headers(headers)) } fn upload_response_header(response: &HttpResponse, header: &str) -> String { response .headers() .get(header) .and_then(|value| value.to_str().ok()) .unwrap_or("missing") .to_string() } fn blob_retry_after(headers: &http::HeaderMap) -> Option { if let Some(delay) = headers .get("x-ms-retry-after-ms") .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse().ok()) { return Some(Duration::from_millis(delay)); } let value = headers.get(http::header::RETRY_AFTER)?.to_str().ok()?; if let Ok(seconds) = value.parse() { return Some(Duration::from_secs(seconds)); } let date = chrono::DateTime::parse_from_rfc2822(value).ok()?; Some( date.signed_duration_since(chrono::Utc::now()) .to_std() .unwrap_or_default(), ) } #[cfg(test)] #[path = "files_retry_tests.rs"] mod retry_tests; #[cfg(test)] mod tests { use super::*; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClientFactory; use codex_http_client::OutboundProxyPolicy; use http::header::HeaderValue; use pretty_assertions::assert_eq; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use wiremock::Mock; use wiremock::MockServer; use wiremock::Request; use wiremock::ResponseTemplate; use wiremock::matchers::body_json; use wiremock::matchers::header; use wiremock::matchers::header_regex; use wiremock::matchers::method; use wiremock::matchers::path; #[derive(Clone, Copy)] pub(super) struct ChatGptTestAuth; pub(super) fn default_http_client_pool() -> RouteAwareClientPool { RouteAwareClientPool::new_without_request_logging( HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ClientRouteClass::Api, ) .with_legacy_custom_ca_fallback() } impl AuthProvider for ChatGptTestAuth { fn add_auth_headers(&self, headers: &mut http::HeaderMap) -> Result<(), crate::AuthError> { headers.insert( http::header::AUTHORIZATION, HeaderValue::from_static("Bearer token"), ); headers.insert("ChatGPT-Account-ID", HeaderValue::from_static("account_id")); Ok(()) } } pub(super) fn chatgpt_auth() -> ChatGptTestAuth { ChatGptTestAuth } pub(super) fn base_url_for(server: &MockServer) -> String { format!("{}/backend-api", server.uri()) } #[tokio::test] async fn upload_openai_file_returns_canonical_uri() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/backend-api/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(serde_json::json!({ "file_name": "hello.txt", "file_size": 5, "use_case": "codex", }))) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!({"file_id": "file_123", "upload_url": format!("{}/upload/file_123", server.uri())})), ) .mount(&server) .await; Mock::given(method("PUT")) .and(path("/upload/file_123")) .and(header("content-length", "5")) .and(header_regex("x-ms-client-request-id", "^[0-9a-f-]{36}$")) .respond_with(ResponseTemplate::new(200)) .mount(&server) .await; let finalize_attempts = Arc::new(AtomicUsize::new(0)); let finalize_attempts_responder = Arc::clone(&finalize_attempts); let download_url = format!("{}/download/file_123", server.uri()); Mock::given(method("POST")) .and(path("/backend-api/files/file_123/uploaded")) .respond_with(move |_request: &Request| { if finalize_attempts_responder.fetch_add(1, Ordering::SeqCst) == 0 { return ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "retry" })); } ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "success", "download_url": download_url, "file_name": "hello.txt", "mime_type": "text/plain", "file_size_bytes": 5 })) }) .mount(&server) .await; let base_url = base_url_for(&server); let open_contents = || async { Ok(futures::stream::iter([Ok(Bytes::from_static(b"hello"))])) }; let uploaded = upload_openai_file( &base_url, &chatgpt_auth(), &default_http_client_pool(), "hello.txt".to_string(), /*file_size_bytes*/ 5, open_contents, /*hosted_upload*/ None, ) .await .expect("upload succeeds"); assert_eq!(uploaded.file_id, "file_123"); assert_eq!(uploaded.uri, "sediment://file_123"); assert_eq!( uploaded.download_url, format!("{}/download/file_123", server.uri()) ); assert_eq!(uploaded.file_name, "hello.txt"); assert_eq!(uploaded.mime_type, Some("text/plain".to_string())); assert_eq!(finalize_attempts.load(Ordering::SeqCst), 2); } #[tokio::test] async fn upload_hosted_app_context_and_finalizes_reservation() { let server = MockServer::start().await; let create_request = serde_json::json!({ "file_name": "report.pdf", "file_size": 8, "use_case": "codex", "codex_connector_id": "library", "codex_action_name": "create_library_file", "codex_model": "gpt-work", }); Mock::given(method("POST")) .and(path("/backend-api/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(create_request.clone())) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_pdf", "upload_url": format!("{}/upload/file_pdf", server.uri()), "pdf_c2pa_reservation": true, }))) .expect(1) .mount(&server) .await; Mock::given(method("PUT")) .and(path("/upload/file_pdf")) .and(header("content-length", "8")) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/backend-api/files/file_pdf/uploaded")) .and(body_json(serde_json::json!({ "pdf_c2pa_create_request": create_request, }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "success", "download_url": format!("{}/download/file_pdf", server.uri()), "file_name": "report.pdf", "mime_type": "application/pdf", "file_size_bytes": 24, }))) .expect(1) .mount(&server) .await; let hosted_upload = HostedFileUploadContext { connector_id: "library".to_string(), action_name: "create_library_file".to_string(), model: "gpt-work".to_string(), }; let uploaded = upload_openai_file( &base_url_for(&server), &chatgpt_auth(), &default_http_client_pool(), "report.pdf".to_string(), /*file_size_bytes*/ 8, || async { Ok(futures::stream::iter([Ok(Bytes::from_static(b"%PDF-1.4"))])) }, Some(&hosted_upload), ) .await .expect("hosted PDF upload succeeds"); assert_eq!(uploaded.file_id, "file_pdf"); assert_eq!(uploaded.file_size_bytes, 24); server.verify().await; } #[tokio::test] async fn upload_hosted_app_preserves_empty_finalization_for_older_servers() { let server = MockServer::start().await; let hosted_upload = HostedFileUploadContext { connector_id: "library".to_string(), action_name: "create_library_file".to_string(), model: "gpt-work".to_string(), }; Mock::given(method("POST")) .and(path("/backend-api/files")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_pdf", "upload_url": format!("{}/upload/file_pdf", server.uri()), }))) .expect(1) .mount(&server) .await; Mock::given(method("PUT")) .and(path("/upload/file_pdf")) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/backend-api/files/file_pdf/uploaded")) .and(body_json(serde_json::json!({}))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "success", "download_url": format!("{}/download/file_pdf", server.uri()), "file_name": "report.pdf", "mime_type": "application/pdf", }))) .expect(1) .mount(&server) .await; let uploaded = upload_openai_file( &base_url_for(&server), &chatgpt_auth(), &default_http_client_pool(), "report.pdf".to_string(), /*file_size_bytes*/ 8, || async { Ok(futures::stream::iter([Ok(Bytes::from_static(b"%PDF-1.4"))])) }, Some(&hosted_upload), ) .await .expect("older servers retain the normal upload behavior"); assert_eq!(uploaded.file_size_bytes, 8); server.verify().await; } #[tokio::test] async fn upload_openai_file_reuses_client_pool_across_uploads() { let server = MockServer::start().await; let files = [ ("first.txt", "file_1", &b"first"[..]), ("second.txt", "file_2", &b"second"[..]), ]; for (file_name, file_id, contents) in files { Mock::given(method("POST")) .and(path("/backend-api/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(serde_json::json!({ "file_name": file_name, "file_size": contents.len(), "use_case": "codex", }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": file_id, "upload_url": format!("{}/upload/{file_id}", server.uri()), }))) .expect(1) .mount(&server) .await; Mock::given(method("PUT")) .and(path(format!("/upload/{file_id}"))) .and(header("content-length", contents.len().to_string())) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path(format!("/backend-api/files/{file_id}/uploaded"))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "success", "download_url": format!("{}/download/{file_id}", server.uri()), "file_name": file_name, "mime_type": "text/plain", "file_size_bytes": contents.len(), }))) .expect(1) .mount(&server) .await; } let client_pool = default_http_client_pool(); let mut uploaded_files = Vec::new(); for (file_name, _, contents) in files { let open_contents = || async { Ok(futures::stream::iter([Ok(Bytes::copy_from_slice( contents, ))])) }; let uploaded = upload_openai_file( &base_url_for(&server), &chatgpt_auth(), &client_pool, file_name.to_string(), u64::try_from(contents.len()).expect("file size should fit in a u64"), open_contents, /*hosted_upload*/ None, ) .await .expect("upload succeeds with the shared client pool"); uploaded_files.push(uploaded); } assert_eq!( uploaded_files, vec![ UploadedOpenAiFile { file_id: "file_1".to_string(), uri: "sediment://file_1".to_string(), download_url: format!("{}/download/file_1", server.uri()), file_name: "first.txt".to_string(), file_size_bytes: 5, mime_type: Some("text/plain".to_string()), }, UploadedOpenAiFile { file_id: "file_2".to_string(), uri: "sediment://file_2".to_string(), download_url: format!("{}/download/file_2", server.uri()), file_name: "second.txt".to_string(), file_size_bytes: 6, mime_type: Some("text/plain".to_string()), }, ] ); server.verify().await; } #[tokio::test] async fn upload_openai_file_reports_blob_response_diagnostics_without_sas() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/backend-api/files")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_123", "upload_url": format!("{}/upload/file_123?sig=secret", server.uri()), }))) .mount(&server) .await; Mock::given(method("PUT")) .and(path("/upload/file_123")) .respond_with( ResponseTemplate::new(500) .insert_header("x-ms-request-id", "azure-request") .insert_header("x-ms-error-code", "ServerBusy") .set_body_string("try again"), ) .mount(&server) .await; let error = upload_openai_file( &base_url_for(&server), &chatgpt_auth(), &default_http_client_pool(), "hello.txt".to_string(), /*file_size_bytes*/ 5, || async { Ok(futures::stream::iter([Ok(Bytes::from_static(b"hello"))])) }, /*hosted_upload*/ None, ) .await .expect_err("blob response failure should be returned"); let message = error.to_string(); assert!(message.contains("failed with status 500")); assert!(message.contains("azure_client_request_id=")); assert!(message.contains("azure_request_id=azure-request")); assert!(message.contains("azure_error_code=ServerBusy")); assert!(!message.contains("try again")); assert!(!message.contains("sig=secret")); } #[tokio::test] async fn upload_openai_file_reports_blob_transport_diagnostics_without_sas() { let upload_listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind upload address"); let upload_address = upload_listener.local_addr().expect("upload address"); drop(upload_listener); let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/backend-api/files")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_123", "upload_url": format!("http://{upload_address}/upload?sig=secret"), }))) .mount(&server) .await; let error = upload_openai_file( &base_url_for(&server), &chatgpt_auth(), &default_http_client_pool(), "hello.txt".to_string(), /*file_size_bytes*/ 5, || async { Ok(futures::stream::iter([Ok(Bytes::from_static(b"hello"))])) }, /*hosted_upload*/ None, ) .await .expect_err("blob transport failure should be returned"); let message = error.to_string(); assert!(message.contains("failed after")); assert!(message.contains("(connect,"), "{message}"); assert!(message.contains("azure_client_request_id=")); assert!(!message.contains("sig=secret")); } }